UNPKG

localgoose

Version:

A lightweight, file-based ODM Database for Node.js, inspired by Mongoose

508 lines (425 loc) 11.9 kB
const { EventEmitter } = require('events'); class Document { // === Core Functionality === constructor(obj, schema, model) { this._doc = { ...obj }; this._schema = schema; this._model = model; this._modifiedPaths = new Set(); this._populated = new Map(); this._parent = null; this._isNew = true; this._snapshot = null; this._session = null; this._locals = {}; this._op = null; this._where = {}; this._timestamps = true; this._deleted = false; this._errors = {}; this._validationError = null; this._selected = new Set(Object.keys(obj)); this._init = new Set(); this.isNew = true; this.errors = {}; this.id = obj._id; this._id = obj._id; // Set up virtuals Object.entries(schema.virtuals).forEach(([path, virtual]) => { Object.defineProperty(this, path, { get: function() { return virtual.applyGetters(undefined, this); }, set: function(value) { return virtual.applySetters(value, this); }, configurable: true }); }); // Set up methods Object.entries(schema.methods).forEach(([name, method]) => { this[name] = method.bind(this); }); // Set up direct property access Object.keys(this._doc).forEach(key => { if (!(key in this)) { Object.defineProperty(this, key, { get: function() { return this._doc[key]; }, set: function(value) { this._doc[key] = value; this._modifiedPaths.add(key); }, configurable: true, enumerable: true }); } }); this.$versioningInit(); } // === Document State Management === init(obj) { return this.$init(obj); } $init(obj) { Object.assign(this._doc, obj); this._modifiedPaths.clear(); this._isNew = false; Object.keys(obj).forEach(key => this._init.add(key)); return this; } $clone() { return new Document({ ...this._doc }, this._schema, this._model); } $isNew() { return this._isNew; } $isDeleted() { return this._deleted; } // === Data Access and Modification === get(path) { return this._doc[path]; } set(path, val) { if (typeof path === 'string') { this._doc[path] = val; this._modifiedPaths.add(path); } else if (typeof path === 'object') { Object.entries(path).forEach(([key, value]) => { this._doc[key] = value; this._modifiedPaths.add(key); }); } return this; } $set(path, val) { return this.set(path, val); } $inc(path, val = 1) { const curVal = this.get(path) || 0; return this.set(path, curVal + val); } overwrite(obj) { this._doc = { _id: this._id, ...obj }; this._modifiedPaths = new Set(Object.keys(obj)); return this; } // === Modification Tracking === isModified(path) { return this.$isModified(path); } $isModified(path) { return path ? this._modifiedPaths.has(path) : this._modifiedPaths.size > 0; } isDirectModified(path) { return this._modifiedPaths.has(path); } markModified(path) { this._modifiedPaths.add(path); return this; } unmarkModified(path) { this._modifiedPaths.delete(path); return this; } modifiedPaths() { return Array.from(this._modifiedPaths); } directModifiedPaths() { return Array.from(this._modifiedPaths); } getChanges() { const changes = {}; for (const path of this._modifiedPaths) { changes[path] = this.get(path); } return changes; } // === Population Methods === async populate(path, select) { if (typeof path === 'string') { const schemaType = this._schema.path(path); if (schemaType && schemaType.options.ref) { const refModel = this._model.db.models[schemaType.options.ref]; if (!refModel) return this; const value = this.get(path); if (!value) return this; try { const populatedDoc = await refModel.findOne({ _id: value }); if (populatedDoc) { this._populated.set(path, populatedDoc); } } catch (error) { console.error(`Error populating ${path}:`, error); } } } return this; } populated(path) { return this.$populated(path); } $populated(path) { return this._populated.get(path); } $assertPopulated(path, values) { if (!this._populated.has(path)) { throw new Error(`Path '${path}' is not populated`); } if (values) { this.$set(values); } return this; } depopulate(path) { if (path) { this._populated.delete(path); } else { this._populated.clear(); } return this; } $getPopulatedDocs() { return Array.from(this._populated.entries()).map(([path, doc]) => ({ path, doc })); } // === Validation Methods === async validate(pathsToValidate) { return this.$validate(pathsToValidate); } validateSync(pathsToValidate) { const paths = pathsToValidate || Array.from(this._modifiedPaths); return paths.map(path => this._validatePath(path)) .filter(Boolean); } async $validate(pathsToValidate) { const paths = pathsToValidate || Array.from(this._modifiedPaths); const errors = await Promise.all( paths.map(path => this._validatePath(path)) ); return errors.filter(Boolean); } async _validatePath(path) { const schemaType = this._schema.path(path); if (!schemaType) return null; const value = this.get(path); return new Promise((resolve) => { schemaType.doValidate(value, (error) => { if (error) { this._errors[path] = error; resolve(error); } else { resolve(null); } }, this); }); } invalidate(path, err) { this._errors[path] = err; return this; } $markValid(path) { delete this._errors[path]; return this; } // === Persistence Operations === async save() { if (this._timestamps) { this._doc.updatedAt = new Date(); if (this.isNew) { this._doc.createdAt = new Date(); } } if (this._schema.middleware.pre.save) { for (const middleware of this._schema.middleware.pre.save) { await middleware.call(this); } } const errors = await this.$validate(); if (errors.length > 0) { throw new Error(errors.join(', ')); } // Increment version key if (this._schema.options.versionKey !== false) { this._doc.__v = (this._doc.__v || 0) + 1; } const result = await this._model.updateOne( { _id: this._id }, this._doc ); if (this._schema.middleware.post.save) { for (const middleware of this._schema.middleware.post.save) { await middleware.call(this); } } this._isNew = false; return result; } async updateOne(update, options = {}) { return this._model.updateOne({ _id: this._id }, update, options); } async replaceOne(replacement, options = {}) { return this._model.replaceOne({ _id: this._id }, replacement, options); } // === State Checks and Utilities === $isEmpty(path) { const val = this.get(path); if (val === null || val === undefined) return true; if (Array.isArray(val)) return val.length === 0; if (typeof val === 'object') return Object.keys(val).length === 0; if (typeof val === 'string') return val.trim().length === 0; return false; } $isDefault(path) { const schemaType = this._schema.path(path); return schemaType ? this.get(path) === schemaType.getDefault() : false; } isInit(path) { return this._init.has(path); } isDirectSelected(path) { return this._selected.has(path); } isSelected(path) { return this._selected.has(path); } equals(doc) { return doc instanceof Document && this._id.toString() === doc._id.toString(); } // === Snapshot and Modified Paths Management === $createModifiedPathsSnapshot() { this._snapshot = new Set(this._modifiedPaths); return this; } $restoreModifiedPathsSnapshot() { if (this._snapshot) { this._modifiedPaths = new Set(this._snapshot); } return this; } $clearModifiedPaths() { this._modifiedPaths.clear(); return this; } $ignore(path) { this._modifiedPaths.delete(path); return this; } // === Subdocument Management === $getAllSubdocs() { const subdocs = []; const addSubdocs = (obj, path = '') => { for (const [key, value] of Object.entries(obj)) { const fullPath = path ? `${path}.${key}` : key; if (value instanceof Document) { subdocs.push({ doc: value, path: fullPath }); } else if (value && typeof value === 'object') { addSubdocs(value, fullPath); } } }; addSubdocs(this._doc); return subdocs; } $parent() { return this._parent; } parent() { return this._parent; } // === Session Management === $session(session = null) { if (arguments.length === 0) return this._session; this._session = session; return this; } // === Configuration and Settings === $timestamps(value = true) { this._timestamps = value; return this; } // === Serialization Methods === toObject(options = {}) { const obj = { ...this._doc }; if (options.getters || options.virtuals) { // Apply virtuals and getters Object.entries(this._schema.virtuals).forEach(([path, virtual]) => { if (options.aliases === false && virtual.isAlias) { return; } obj[path] = virtual.applyGetters(undefined, this); }); } if (options.minimize) { // Remove empty objects for (const key in obj) { if (this.$isEmpty(key)) { delete obj[key]; } } } if (options.versionKey === false && this._schema.options.versionKey) { delete obj[this._schema.options.versionKey]; } if (options.transform) { return options.transform(this, obj, options); } return obj; } toJSON(options = {}) { options = { flattenMaps: true, // Default true for JSON ...options }; return this.toObject(options); } toString() { return `Document { _id: ${this._id} }`; } inspect() { return this.toObject(); } // === Getters === get schema() { return this._schema; } get $errors() { return this._errors; } get $locals() { return this._locals; } get $op() { return this._op; } get $where() { return this._where; } // === Versioning Methods === $versioningInit() { if (this._schema.options.versionKey) { this._doc.__v = 0; this._lastVersion = 0; } } $incVersion() { if (this._schema.options.versionKey) { this._doc.__v = (this._doc.__v || 0) + 1; this._lastVersion = this._doc.__v; } } $checkVersion() { if (this._schema.options.versionKey && this._lastVersion !== this._doc.__v) { throw new Error('VersionError: Document has been modified since retrieval'); } } // Enhanced change tracking $isSelected(path) { if (!this._selected) return true; return this._selected.has(path); } } module.exports = { Document };