localgoose
Version:
A lightweight, file-based ODM Database for Node.js, inspired by Mongoose
452 lines (405 loc) • 16.5 kB
JavaScript
const { SchemaType } = require('./SchemaType.js');
const { VirtualType } = require('./VirtualType.js');
const { ObjectId, Decimal128 } = require('bson');
// All valid Mongoose-compatible middleware hooks
const VALID_HOOKS = [
'init', 'validate', 'save', 'remove',
'deleteOne', 'deleteMany',
'find', 'findOne', 'findOneAndUpdate', 'findOneAndRemove', 'findOneAndDelete',
'updateOne', 'updateMany',
'count', 'countDocuments', 'estimatedDocumentCount',
'aggregate', 'insertMany'
];
class Schema {
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.obj = { ...(definition || {}) };
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._searchIndexes = new Map();
this._doc = {};
this._queue = new Map();
this.reserved = Schema.reserved;
if (options.timestamps) {
this.definition.createdAt = { type: Date, default: Date.now };
this.definition.updatedAt = { type: Date, default: Date.now };
}
if (options.versionKey !== false) {
const vKey = typeof options.versionKey === 'string' ? options.versionKey : '__v';
this.definition[vKey] = { 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 [p, opts] of Object.entries(this.definition)) {
try {
this._paths.set(p, this._createSchemaType(p, opts));
if (opts && opts.required) this._requiredPaths.add(p);
if (opts && opts.index) this._indexes.push({ fields: { [p]: 1 }, options: {} });
} catch (e) { /* skip paths that cant create SchemaType */ }
}
}
_parseDefinition(definition) {
if (!definition || typeof definition !== 'object') return {};
const parsed = {};
for (const [key, value] of Object.entries(definition)) {
if (value === null || value === undefined) {
parsed[key] = { type: Object };
} else if (value instanceof Schema) {
parsed[key] = { type: Object, schema: value };
} else if (typeof value === 'function') {
// Shorthand: field: String
parsed[key] = { type: value };
} else if (Array.isArray(value)) {
const itemDef = value[0];
const isPlainObj = typeof itemDef === 'object' && itemDef !== null && !Array.isArray(itemDef) && !(itemDef instanceof Schema);
const schema = isPlainObj ? new Schema(itemDef) : (itemDef instanceof Schema ? itemDef : null);
parsed[key] = { type: value, schema };
} else if (typeof value === 'object') {
if (value.type instanceof Schema) {
parsed[key] = { ...value, schema: value.type, type: Object };
} else if (value.type && Array.isArray(value.type)) {
const itemDef = value.type[0];
const isPlainObj = typeof itemDef === 'object' && itemDef !== null && !Array.isArray(itemDef) && !(itemDef instanceof Schema);
const schema = isPlainObj ? new Schema(itemDef) : (itemDef instanceof Schema ? itemDef : null);
parsed[key] = { ...value, type: value.type, schema };
} else if (value.type) {
parsed[key] = {
...value,
isReference: value.type === Schema.Types.ObjectId && !!value.ref,
validate: value.validate || null
};
} else {
// Nested object without type: treat as Mixed/Object
parsed[key] = { type: Object, _nestedDef: value };
}
} else {
parsed[key] = { type: value };
}
}
return parsed;
}
_createSchemaType(p, options) {
if (!options) return new SchemaType(p, {}, Object);
const type = options.type !== undefined ? options.type : options;
if (type === undefined) return new SchemaType(p, {}, Object);
// Allow any type including custom classes — do NOT throw for unknown types
const opts = typeof options === 'object' ? options : {};
const st = new SchemaType(p, opts, type);
if (opts.default !== undefined) st.default(opts.default);
return st;
}
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, Number, Boolean, Array, Date, Object,
ObjectId, Mixed: Object, Decimal128, Map, Buffer,
UUID: String, BigInt, Subdocument: Object, Embedded: Object
};
}
static get indexTypes() {
return ['2d', '2dsphere', 'hashed', 'text', 'unique', 'sparse', 'compound'];
}
// === Schema Modification ===
add(fieldOrSchema, singleValue) {
if (fieldOrSchema instanceof Schema) {
const other = fieldOrSchema;
for (const [p, def] of Object.entries(other.obj)) {
this.obj[p] = def;
const parsed = this._parseDefinition({ [p]: def });
this.definition[p] = parsed[p];
try {
this._paths.set(p, this._createSchemaType(p, parsed[p]));
if (parsed[p] && parsed[p].required) this._requiredPaths.add(p);
} catch (e) {}
}
Object.assign(this.methods, other.methods);
Object.assign(this.statics, other.statics);
Object.assign(this.virtuals, other.virtuals);
} else {
const obj = fieldOrSchema;
for (const [p, def] of Object.entries(obj)) {
const defValue = singleValue !== undefined ? singleValue : def;
this.obj[p] = defValue;
const parsed = this._parseDefinition({ [p]: defValue });
this.definition[p] = parsed[p];
try {
this._paths.set(p, this._createSchemaType(p, parsed[p]));
if (parsed[p] && parsed[p].required) this._requiredPaths.add(p);
} catch (e) {}
}
}
return this;
}
remove(p) {
delete this.definition[p];
delete this.obj[p];
this._paths.delete(p);
this._requiredPaths.delete(p);
return this;
}
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');
this._indexes.push({ fields, options });
return this;
}
path(p) { return this._paths.get(p); }
pathType(p) {
if (this._paths.has(p)) return 'real';
if (this.virtuals[p]) return 'virtual';
if (this.reserved[p]) return 'reserved';
return 'adhoc';
}
clone() {
const clone = new Schema(this.obj, { ...this.options });
clone.virtuals = { ...this.virtuals };
clone.methods = { ...this.methods };
clone.statics = { ...this.statics };
clone.middleware = {
pre: Object.fromEntries(Object.entries(this.middleware.pre).map(([k, v]) => [k, [...v]])),
post: Object.fromEntries(Object.entries(this.middleware.post).map(([k, v]) => [k, [...v]]))
};
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;
}
pre(action, fn) {
if (!VALID_HOOKS.includes(action)) {
throw new Error(`Invalid hook: ${action}. Valid hooks are: ${VALID_HOOKS.join(', ')}`);
}
if (!this.middleware.pre[action]) this.middleware.pre[action] = [];
this.middleware.pre[action].push(fn);
return this;
}
post(action, fn) {
if (!VALID_HOOKS.includes(action)) {
throw new Error(`Invalid hook: ${action}. Valid hooks are: ${VALID_HOOKS.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(name, options = {}) {
if (typeof name === 'object') {
for (const [p, opts] of Object.entries(name)) this.virtual(p, opts);
return this;
}
const opts = typeof options === 'object' ? { ...options, path: name } : { path: name };
if (!this.virtuals[name]) this.virtuals[name] = new VirtualType(opts);
return this.virtuals[name];
}
virtualpath(name) { return this.virtuals[name]; }
method(name, fn) {
if (typeof name === 'object') Object.assign(this.methods, name);
else this.methods[name] = fn;
return this;
}
static(name, fn) {
if (typeof name === 'object') Object.assign(this.statics, name);
else this.statics[name] = fn;
return this;
}
loadClass(model) {
Object.getOwnPropertyNames(model.prototype)
.filter(n => n !== 'constructor')
.forEach(n => this.method(n, model.prototype[n]));
Object.getOwnPropertyNames(model)
.filter(n => typeof model[n] === 'function')
.forEach(n => this.static(n, model[n]));
return this;
}
eachPath(fn) { this._paths.forEach((st, p) => fn(p, st)); }
requiredPaths(invalidate = false) {
if (invalidate) {
this._requiredPaths.clear();
this.eachPath((p, st) => { if (st.isRequired) this._requiredPaths.add(p); });
}
return Array.from(this._requiredPaths);
}
indexes() { return [...this._indexes]; }
clearIndexes() { this._indexes = []; return this; }
searchIndex(options = {}) {
if (!options.name) throw new Error('Search index must have a name');
if (!options.definition) throw new Error('Search index must have a definition');
this._searchIndexes.set(options.name, {
weights: options.weights || {},
name: options.name,
definition: options.definition,
default_language: options.default_language || 'english',
language_override: options.language_override || 'language'
});
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 => 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) {
(Array.isArray(paths) ? paths : [paths]).forEach(p => delete this.virtuals[p]);
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((p, st) => {
let property = {};
if (st.instance === ObjectId) {
property[typeKey] = useBsonType ? 'objectId' : 'string';
} else {
property[typeKey] = st.instance?.name?.toLowerCase() || 'mixed';
}
if (st.enumValues && st.enumValues.length) property.enum = st.enumValues;
jsonSchema.properties[p] = property;
});
return jsonSchema;
}
get(key) { return this.options[key]; }
set(key, value) { this.options[key] = value; return this; }
// === Validation ===
_validatePath(p, value) {
const st = this._paths.get(p);
if (!st) return null;
if (st.options.required) {
const isReq = Array.isArray(st.options.required) ? st.options.required[0] : st.options.required;
const msg = Array.isArray(st.options.required) ? st.options.required[1] : `Path \`${p}\` is required.`;
if (isReq && (value == null || value === '')) return msg;
}
if (value == null) return null; // skip other validations for null/undefined
if (st.options.min != null) {
const min = Array.isArray(st.options.min) ? st.options.min[0] : st.options.min;
const msg = Array.isArray(st.options.min) ? st.options.min[1] : `${p} should be at least ${min}`;
if (value < min) return msg;
}
if (st.options.max != null) {
const max = Array.isArray(st.options.max) ? st.options.max[0] : st.options.max;
const msg = Array.isArray(st.options.max) ? st.options.max[1] : `${p} should be at most ${max}`;
if (value > max) return msg;
}
if (st.options.minlength != null && typeof value === 'string' && value.length < st.options.minlength) {
return `${p} must be at least ${st.options.minlength} characters`;
}
if (st.options.maxlength != null && typeof value === 'string' && value.length > st.options.maxlength) {
return `${p} must be at most ${st.options.maxlength} characters`;
}
if (st.options.match) {
const regex = Array.isArray(st.options.match) ? st.options.match[0] : st.options.match;
const msg = Array.isArray(st.options.match) ? st.options.match[1] : `${p} does not match required pattern`;
if (typeof value === 'string' && !regex.test(value)) return msg;
}
if (st.options.enum) {
if (!st.options.enum.includes(value)) return `${p} must be one of: ${st.options.enum.join(', ')}`;
}
if (st.options.validate) {
const validator = st.options.validate;
if (typeof validator === 'function') {
const result = validator(value);
if (result !== true) return result || `${p} validation failed`;
} else if (typeof validator === 'object' && validator.validator) {
if (!validator.validator(value)) {
const message = typeof validator.message === 'function'
? validator.message({ value, path: p })
: validator.message || `${p} validation failed`;
return message;
}
}
}
return null;
}
validate(data) {
const errors = [];
for (const [p] of this._paths.entries()) {
const error = this._validatePath(p, data[p]);
if (error) errors.push(error);
}
return errors;
}
queue(name, args) {
if (!this._queue.has(name)) this._queue.set(name, []);
this._queue.get(name).push(args);
return this;
}
omit(paths) {
const clone = this.clone();
(Array.isArray(paths) ? paths : [paths]).forEach(p => clone.remove(p));
return clone;
}
pick(paths) {
const newSchema = new Schema({});
(Array.isArray(paths) ? paths : [paths]).forEach(p => {
if (this.obj[p]) newSchema.add({ [p]: this.obj[p] });
});
return newSchema;
}
get paths() { return Object.fromEntries(this._paths); }
nested(p) {
const parts = p.split('.');
let current = this.definition;
let nested = false;
for (const part of parts) {
if (current[part] && typeof current[part] === 'object') { nested = true; current = current[part]; }
else break;
}
return nested;
}
isArray(p) {
const st = this._paths.get(p);
return st && (Array.isArray(st.instance) || st.instance === Array);
}
extend(schema) {
if (!(schema instanceof Schema)) throw new Error('extend() argument must be a Schema');
return this.add(schema);
}
}
module.exports = { Schema };