localgoose
Version:
A lightweight, file-based ODM Database for Node.js, inspired by Mongoose
606 lines (526 loc) • 16.3 kB
JavaScript
const { SchemaType } = require('./SchemaType.js');
const { VirtualType } = require('./VirtualType.js');
const { ObjectId, Decimal128 } = require('bson'); // Added Decimal128
class Schema {
// === Core Functionality ===
constructor(definition, options = {}) {
if (definition !== undefined && definition !== null && typeof definition !== 'object') {
throw new TypeError('Schema definition must be an object or null');
}
if (Array.isArray(definition)) {
throw new TypeError('Schema definition cannot be an array');
}
this.definition = this._parseDefinition(definition);
this.options = options;
this.virtuals = {};
this.methods = {};
this.statics = {};
this.middleware = {
pre: {},
post: {}
};
this._indexes = [];
this._paths = new Map();
this._requiredPaths = new Set();
this._plugins = new Set();
this.childSchemas = [];
this.discriminatorMapping = null;
this.obj = { ...definition };
this._searchIndexes = new Map();
this._doc = {};
this._queue = new Map();
this.reserved = Schema.reserved;
// Handle schema options
if (options.timestamps) {
this.definition.createdAt = { type: Date, default: Date.now };
this.definition.updatedAt = { type: Date, default: Date.now };
}
if (options.versionKey !== false) {
this.definition.__v = { type: Number, default: 0 };
}
this.strict = options.strict !== undefined ? options.strict : true;
this.minimize = options.minimize !== undefined ? options.minimize : true;
this._init();
}
_init() {
for (const [path, options] of Object.entries(this.definition)) {
this._paths.set(path, this._createSchemaType(path, options));
if (options.required) {
this._requiredPaths.add(path);
}
if (options.index) {
this.index({ [path]: 1 });
}
}
}
_parseDefinition(definition) {
const parsed = {};
for (const [key, value] of Object.entries(definition)) {
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
if (value instanceof Schema) {
// Handle nested schema
parsed[key] = {
type: Object,
schema: value
};
} else if (value.type && value.type instanceof Schema) {
// Handle array of nested schemas
parsed[key] = {
...value,
schema: value.type
};
} else if (value.type && Array.isArray(value.type)) {
// Handle array of subdocuments
parsed[key] = {
type: Array,
schema: new Schema(value.type[0])
};
} else if (value.type) {
parsed[key] = {
...value,
isReference: value.type === Schema.Types.ObjectId && value.ref,
validate: value.validate || null // Add support for custom validators
};
} else {
parsed[key] = this._parseDefinition(value);
}
} else {
parsed[key] = { type: value };
}
}
return parsed;
}
_createSchemaType(path, options) {
const type = options.type || options;
if (type === undefined) {
throw new Error(`No type specified for path '${path}'`);
}
if (!Schema.Types[type.name] && !(type instanceof Schema)) {
throw new Error(`Invalid type ${type} specified for path '${path}'`);
}
const schemaTypeOptions = typeof options === 'object' ? options : {};
const schemaType = new SchemaType(path, schemaTypeOptions, type);
// Handle default values
if (options.default !== undefined) {
schemaType.default(options.default);
}
return schemaType;
}
// === Static Properties ===
static get reserved() {
return {
_id: true,
__v: true,
createdAt: true,
updatedAt: true,
collection: true,
emit: true,
errors: true,
get: true,
init: true,
isModified: true,
isNew: true,
listeners: true,
modelName: true,
on: true,
once: true,
populated: true,
remove: true,
removeListener: true,
save: true,
schema: true,
set: true,
toObject: true,
validate: true
};
}
static get Types() {
return {
String: String,
Number: Number,
Boolean: Boolean,
Array: Array,
Date: Date,
Object: Object,
ObjectId: ObjectId,
Mixed: Object,
Decimal128: Decimal128,
Map: Map,
Buffer: Buffer,
UUID: String,
BigInt: BigInt,
Subdocument: Object,
Embedded: Object
};
}
static get indexTypes() {
return ['2d', '2dsphere', 'hashed', 'text', 'unique', 'sparse', 'compound'];
}
// === Schema Modification Methods ===
add(obj) {
for (const [path, options] of Object.entries(obj)) {
this.definition[path] = options;
this._paths.set(path, this._createSchemaType(path, options));
if (options.required) {
this._requiredPaths.add(path);
}
}
return this;
}
remove(path) {
delete this.definition[path];
this._paths.delete(path);
this._requiredPaths.delete(path);
return this;
}
// === Schema Configuration ===
alias(from, to) {
this.virtual(from).get(function() {
return this[to];
});
return this;
}
index(fields, options = {}) {
if (typeof fields !== 'object' || Array.isArray(fields)) {
throw new TypeError('Index fields must be an object');
}
for (const [path, value] of Object.entries(fields)) {
if (![-1, 1, '2d', '2dsphere', 'geoHaystack', 'hashed', 'text'].includes(value)) {
throw new Error(`Invalid index value '${value}' for path '${path}'`);
}
}
const index = { fields, options };
this._indexes.push(index);
return this;
}
path(path) {
return this._paths.get(path);
}
pathType(path) {
if (this._paths.has(path)) return 'real';
if (this.virtuals[path]) return 'virtual';
if (this.reserved[path]) return 'reserved';
return 'adhoc';
}
// === Schema Operations ===
clone() {
const clone = new Schema(this.definition, { ...this.options });
clone.virtuals = { ...this.virtuals };
clone.methods = { ...this.methods };
clone.statics = { ...this.statics };
clone.middleware = {
pre: { ...this.middleware.pre },
post: { ...this.middleware.post }
};
clone._indexes = [...this._indexes];
clone._plugins = new Set([...this._plugins]);
clone.childSchemas = [...this.childSchemas];
clone._searchIndexes = new Map(this._searchIndexes);
return clone;
}
discriminator(name, schema) {
if (!this.discriminatorMapping) {
this.discriminatorMapping = {
key: '_type',
value: this.options.name || 'Base'
};
}
schema.discriminatorMapping = {
key: this.discriminatorMapping.key,
value: name
};
this.childSchemas.push({ name, schema });
return schema;
}
// === Middleware and Plugins ===
pre(action, fn) {
const validHooks = ['init', 'validate', 'save', 'remove', 'deleteOne', 'deleteMany'];
if (!validHooks.includes(action)) {
throw new Error(`Invalid hook: ${action}. Valid hooks are: ${validHooks.join(', ')}`);
}
if (!this.middleware.pre[action]) {
this.middleware.pre[action] = [];
}
this.middleware.pre[action].push(fn);
return this;
}
post(action, fn) {
const validHooks = ['init', 'validate', 'save', 'remove', 'deleteOne', 'deleteMany'];
if (!validHooks.includes(action)) {
throw new Error(`Invalid hook: ${action}. Valid hooks are: ${validHooks.join(', ')}`);
}
if (!this.middleware.post[action]) {
this.middleware.post[action] = [];
}
this.middleware.post[action].push(fn);
return this;
}
plugin(fn, opts) {
fn(this, opts || {});
this._plugins.add(fn);
return this;
}
// === Virtual Fields ===
virtual(name) {
if (!this.virtuals[name]) {
this.virtuals[name] = new VirtualType({ path: name });
}
return this.virtuals[name];
}
virtualpath(name) {
return this.virtuals[name];
}
// === Methods and Statics ===
method(name, fn) {
this.methods[name] = fn;
return this;
}
static(name, fn) {
this.statics[name] = fn;
return this;
}
loadClass(model) {
const methods = Object.getOwnPropertyNames(model.prototype)
.filter(name => name !== 'constructor');
methods.forEach(method => {
this.method(method, model.prototype[method]);
});
const statics = Object.getOwnPropertyNames(model)
.filter(name => typeof model[name] === 'function');
statics.forEach(staticMethod => {
this.static(staticMethod, model[staticMethod]);
});
return this;
}
// === Schema Traversal ===
eachPath(fn) {
this._paths.forEach((schemaType, path) => {
fn(path, schemaType);
});
}
requiredPaths(invalidate = false) {
if (invalidate) {
this._requiredPaths.clear();
this.eachPath((path, schemaType) => {
if (schemaType.required()) {
this._requiredPaths.add(path);
}
});
}
return Array.from(this._requiredPaths);
}
// === Index Management ===
indexes() {
return [...this._indexes];
}
clearIndexes() {
this._indexes = [];
return this;
}
searchIndex(options = {}) {
if (typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('Search index options must be an object');
}
if (!options.name) {
throw new Error('Search index must have a name');
}
if (!options.definition) {
throw new Error('Search index must have a definition');
}
const index = {
weights: options.weights || {},
name: options.name,
definition: options.definition,
default_language: options.default_language || 'english',
language_override: options.language_override || 'language'
};
this._searchIndexes.set(options.name, index);
return this;
}
removeIndex(index) {
if (typeof index === 'string') {
this._indexes = this._indexes.filter(idx => idx.options.name !== index);
} else if (typeof index === 'object' && !Array.isArray(index)) {
this._indexes = this._indexes.filter(idx => {
return JSON.stringify(idx.fields) !== JSON.stringify(index);
});
} else {
throw new TypeError('Index parameter must be a string name or an object specification');
}
return this;
}
removeVirtual(paths) {
paths = Array.isArray(paths) ? paths : [paths];
paths.forEach(path => {
delete this.virtuals[path];
});
return this;
}
toJSONSchema(options = {}) {
const useBsonType = options.useBsonType || false;
const typeKey = useBsonType ? 'bsonType' : 'type';
const jsonSchema = {
type: 'object',
required: ['_id', ...this.requiredPaths()],
properties: {}
};
this.eachPath((path, schemaType) => {
let property = {};
if (useBsonType) {
if (schemaType.instance === 'ObjectID') {
property[typeKey] = 'objectId';
} else {
property[typeKey] = [schemaType.instance.toLowerCase(), 'null'];
}
} else {
if (schemaType.instance === 'ObjectID') {
property[typeKey] = 'string';
} else {
property[typeKey] = [schemaType.instance.toLowerCase(), 'null'];
}
}
if (schemaType.enumValues && schemaType.enumValues.length) {
property.enum = schemaType.enumValues;
}
jsonSchema.properties[path] = property;
});
return jsonSchema;
}
// === Schema Options ===
get(key) {
return this.options[key];
}
set(key, value) {
this.options[key] = value;
return this;
}
// === Validation ===
_validatePath(path, value) {
const schemaType = this._paths.get(path);
if (!schemaType) return null;
// Built-in validators
if (schemaType.options.required && value == null) {
return `${path} is required`;
}
if (schemaType.options.min != null && value < schemaType.options.min) {
return `${path} should be at least ${schemaType.options.min}`;
}
if (schemaType.options.max != null && value > schemaType.options.max) {
return `${path} should be at most ${schemaType.options.max}`;
}
// Custom validators
if (schemaType.options.validate) {
const validator = schemaType.options.validate;
if (typeof validator === 'function') {
const result = validator(value);
if (result !== true) {
return result || `${path} validation failed`;
}
} else if (typeof validator === 'object') {
if (!validator.validator(value)) {
return validator.message || `${path} validation failed`;
}
}
}
return null;
}
validate(data) {
const errors = [];
for (const [path, schemaType] of this._paths.entries()) {
const value = data[path];
const error = this._validatePath(path, value);
if (error) {
errors.push(error);
}
}
return errors;
}
// === Additional Methods ===
queue(name, args) {
if (!this._queue) this._queue = new Map();
if (!this._queue.has(name)) this._queue.set(name, []);
this._queue.get(name).push(args);
return this;
}
omit(paths) {
const newSchema = this.clone();
paths = Array.isArray(paths) ? paths : [paths];
paths.forEach(path => {
newSchema.remove(path);
});
return newSchema;
}
pick(paths) {
const newSchema = new Schema({});
paths = Array.isArray(paths) ? paths : [paths];
paths.forEach(path => {
if (this._paths.has(path)) {
newSchema.add({ [path]: this.definition[path] });
}
});
return newSchema;
}
version(condition, versionKey = '__v') {
if (!this.options.versionKey) {
this.options.versionKey = versionKey;
}
this.pre('save', async function() {
const shouldVersion = typeof condition === 'function'
? await condition.call(this)
: true;
if (shouldVersion) {
this[versionKey] = (this[versionKey] || 0) + 1;
}
});
return this;
}
get paths() {
return Object.fromEntries(this._paths);
}
toObject(options = {}) {
const obj = { ...this._doc };
if (this.minimize) {
for (const key in obj) {
if (obj[key] === undefined) {
delete obj[key];
}
}
}
return obj;
}
// Method to handle nested paths
nested(path) {
const parts = path.split('.');
let nested = false;
let current = this.definition;
for (let i = 0; i < parts.length; i++) {
if (current[parts[i]] && typeof current[parts[i]] === 'object') {
nested = true;
current = current[parts[i]];
} else {
break;
}
}
return nested;
}
// Method to handle array paths
isArray(path) {
const schemaType = this._paths.get(path);
return schemaType && (
Array.isArray(schemaType.instance) ||
schemaType.instance === Array
);
}
// Method for schema inheritance
extend(schema) {
if (!(schema instanceof Schema)) {
throw new Error('extend() argument must be a Schema');
}
this.add(schema.definition);
this.methods = { ...this.methods, ...schema.methods };
this.statics = { ...this.statics, ...schema.statics };
this.virtuals = { ...this.virtuals, ...schema.virtuals };
return this;
}
}
module.exports = { Schema };