UNPKG

localgoose

Version:

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

588 lines (516 loc) 24.9 kB
const { QueryBuilder } = require('./QueryBuilder.js'); const { Document } = require('./Document.js'); const { getNestedValue } = require('./utils.js'); class Query { constructor(model, conditions = {}) { this.model = model; this.conditions = { ...conditions }; this._conditions = this.conditions; // alias this._fields = {}; this._sort = {}; this._limit = null; this._skip = null; this._populate = []; this._lean = false; this._batchSize = null; this._readPreference = null; this._hint = null; this._comment = null; this._maxTimeMS = null; this._tailable = false; this._session = null; this._options = {}; this._update = null; this._distinct = null; this._error = null; this._explain = false; this._mongooseOptions = {}; this._geoComparison = null; this._middleware = { pre: [], post: [] }; this._geometry = null; this._writeConcern = {}; this._readConcern = null; this._transform = null; this._currentPath = null; } // ─── Execution ──────────────────────────────────────────────────────────── async exec() { if (this._error) throw this._error; // Run pre-find / pre-findOne middleware const hookName = this._limit === 1 ? 'findOne' : 'find'; // Apply geometry filter if present if (this._geometry) { const { type, path, coordinates } = this._geometry; this.conditions[path] = { ...this.conditions[path], [`$${type}`]: coordinates }; } // Apply geo comparison filter if present (near, nearSphere, geoIntersects) if (this._geoComparison && this._currentPath) { this.conditions[this._currentPath] = { ...this.conditions[this._currentPath], ...this._geoComparison }; } let docs = await this.model._find(this.conditions); // Sort if (Object.keys(this._sort).length > 0) { docs.sort((a, b) => { for (const [field, order] of Object.entries(this._sort)) { const av = getNestedValue(a, field); const bv = getNestedValue(b, field); if (av < bv) return -1 * order; if (av > bv) return order; } return 0; }); } if (this._skip) docs = docs.slice(this._skip); if (this._limit) docs = docs.slice(0, this._limit); // Field projection if (Object.keys(this._fields).length > 0) { docs = docs.map(doc => this._applyProjection(doc, this._fields)); } // Lean mode if (this._lean) { const result = this._limit === 1 ? (docs[0] || null) : docs; await this._runSchemaMiddleware('post', hookName, result); return result; } const documents = docs.map(doc => { const d = new Document(doc, this.model.schema, this.model); d._isNew = false; d.isNew = false; return d; }); // Population if (this._populate.length > 0) { const populated = await Promise.all(documents.map(d => this._populateDoc(d))); const result = this._limit === 1 ? (populated[0] || null) : populated; await this._runSchemaMiddleware('post', hookName, result); if (this._transform) { return Array.isArray(result) ? result.map(this._transform) : (result ? this._transform(result) : null); } return result; } if (this._transform) { const transformed = documents.map(this._transform); return this._limit === 1 ? (transformed[0] || null) : transformed; } const result = this._limit === 1 ? (documents[0] || null) : documents; await this._runSchemaMiddleware('post', hookName, result); return result; } // Run schema-level pre/post hooks (find, findOne, etc.) async _runSchemaMiddleware(type, action, doc) { const hooks = this.model.schema.middleware[type][action] || []; for (const fn of hooks) await fn.call(this, doc); } // Projection helper _applyProjection(doc, fields) { const hasInclusions = Object.values(fields).some(v => v === 1); if (hasInclusions) { const result = {}; if (fields._id !== 0) result._id = doc._id; for (const [f, v] of Object.entries(fields)) { if (v === 1) result[f] = doc[f]; } return result; } // Exclusive projection const result = { ...doc }; for (const [f, v] of Object.entries(fields)) { if (v === 0) delete result[f]; } return result; } // Population helper async _populateDoc(doc) { const populatedDoc = new Document({ ...doc._doc }, this.model.schema, this.model); populatedDoc._isNew = false; populatedDoc.isNew = false; for (const populate of this._populate) { const { path, select, match, options: popOptions } = typeof populate === 'string' ? { path: populate } : populate; const pathSegments = path.split('.'); let currentDoc = populatedDoc; let currentPath = ''; for (const segment of pathSegments) { currentPath = currentPath ? `${currentPath}.${segment}` : segment; let pathSchema = this.model.schema._paths.get(currentPath); let virtual = this.model.schema.virtuals[currentPath]; let refOptions = (pathSchema && pathSchema.options) || (virtual && virtual.options); if (!refOptions || !refOptions.ref) { currentDoc = currentDoc ? currentDoc[segment] : undefined; continue; } const refModel = this.model.db.models[refOptions.ref]; if (!refModel) { currentDoc = currentDoc ? currentDoc[segment] : undefined; continue; } try { if (virtual) { // Virtual population const localValue = currentDoc.get(refOptions.localField || '_id'); if (localValue === undefined) { currentDoc = undefined; continue; } let q = refModel.find({ [refOptions.foreignField || '_id']: localValue }); if (select) q = q.select(select); if (match) q = q.where(match); if (popOptions) q = q.option(popOptions); let pv = await q.exec(); if (refOptions.justOne) pv = Array.isArray(pv) ? (pv[0] || null) : pv; currentDoc._populated.set(segment, pv); Object.defineProperty(currentDoc, segment, { get: function() { return this._populated.get(segment); }, configurable: true, enumerable: true }); // Also update _doc for toObject/toJSON currentDoc._doc[segment] = pv; } else { // Standard ref population const value = currentDoc ? currentDoc._doc ? currentDoc._doc[segment] : currentDoc[segment] : undefined; if (!value) { currentDoc = undefined; continue; } if (Array.isArray(value)) { let populatedValues = await Promise.all(value.map(id => { let q = refModel.findOne({ _id: id }); if (select) q = q.select(select); if (match) Object.assign(q.conditions, match); return q.exec(); })); populatedValues = populatedValues.filter(Boolean); if (currentDoc._doc) currentDoc._doc[segment] = populatedValues; currentDoc[segment] = populatedValues; currentDoc._populated.set(segment, populatedValues); } else { let q = refModel.findOne({ _id: value }); if (select) q = q.select(select); if (match) Object.assign(q.conditions, match); const pv = await q.exec(); if (pv) { if (currentDoc._doc) currentDoc._doc[segment] = pv; currentDoc[segment] = pv; currentDoc._populated.set(segment, pv); } } } } catch (err) { console.error(`Error populating ${currentPath}:`, err); } currentDoc = currentDoc ? currentDoc[segment] : undefined; } } return populatedDoc; } clone() { const c = new Query(this.model, { ...this.conditions }); c._fields = { ...this._fields }; c._sort = { ...this._sort }; c._limit = this._limit; c._skip = this._skip; c._populate = [...this._populate]; c._lean = this._lean; c._options = { ...this._options }; c._batchSize = this._batchSize; c._readPreference = this._readPreference; c._hint = this._hint; c._comment = this._comment; c._maxTimeMS = this._maxTimeMS; c._tailable = this._tailable; c._session = this._session; c._update = this._update ? { ...this._update } : null; c._distinct = this._distinct; c._error = this._error; c._explain = this._explain; c._mongooseOptions = { ...this._mongooseOptions }; c._geoComparison = this._geoComparison; c._middleware = { pre: [...this._middleware.pre], post: [...this._middleware.post] }; c._geometry = this._geometry; c._writeConcern = { ...this._writeConcern }; c._readConcern = this._readConcern; c._transform = this._transform; return c; } // Thenable — so Query works with await without .exec() then(resolve, reject) { return this.exec().then(resolve, reject); } catch(fn) { return this.exec().catch(fn); } finally(fn) { return this.exec().finally(fn); } // ─── CRUD convenience methods ────────────────────────────────────────────── async findOne(conditions = {}) { Object.assign(this.conditions, conditions); this._limit = 1; return this.exec(); } async findById(id) { this.conditions._id = typeof id === 'string' ? id : id.toString(); this._limit = 1; return this.exec(); } async findOneAndDelete(conditions = {}) { Object.assign(this.conditions, conditions); const doc = await this.exec(); if (doc) await this.model.deleteOne({ _id: doc._id }); return doc; } async findOneAndReplace(conditions, replacement, options = {}) { Object.assign(this.conditions, conditions); const doc = await this.exec(); if (doc) { Object.assign(doc._doc, replacement); await doc.save(); } else if (options.upsert) return this.model.create(replacement); return doc; } async findOneAndUpdate(conditions, update, options = {}) { if (conditions) Object.assign(this.conditions, conditions); const doc = await this.model.findOneAndUpdate(this.conditions, update, options); return doc; } async findByIdAndUpdate(id, update, options = {}) { return this.model.findByIdAndUpdate(id, update, options); } async findByIdAndDelete(id) { return this.model.findByIdAndDelete(id); } async deleteMany(conditions = {}) { return this.model.deleteMany({ ...this.conditions, ...conditions }); } async deleteOne(conditions = {}) { return this.model.deleteOne({ ...this.conditions, ...conditions }); } async updateMany(conditions, update, opts) { return this.model.updateMany({ ...this.conditions, ...conditions }, update, opts); } async updateOne(conditions, update, opts) { return this.model.updateOne({ ...this.conditions, ...conditions }, update, opts); } async replaceOne(conditions, doc, opts) { return this.model.replaceOne({ ...this.conditions, ...conditions }, doc, opts); } // ─── Query Building ──────────────────────────────────────────────────────── where(path) { if (typeof path === 'object') { Object.assign(this.conditions, path); return this; } this._currentPath = path; // Return QueryBuilder but also ensure Query methods honour _currentPath afterwards const qb = new QueryBuilder(this, path); return qb; } equals(val) { if (typeof val === 'object' && val !== null) this.conditions = val; else this.conditions._id = val; return this; } _setConditional(path, val, op) { const p = arguments.length === 2 ? this._currentPath : path; const v = arguments.length === 2 ? path : val; const existing = this.conditions[p]; if (existing && typeof existing === 'object' && !Array.isArray(existing) && !(existing instanceof RegExp)) { this.conditions[p] = { ...existing, [op]: v }; } else { this.conditions[p] = { [op]: v }; } return this; } gt(path, val) { if (arguments.length === 1) { this._setConditional(this._currentPath, path, '$gt'); return this; } return this._setConditional(path, val, '$gt'); } gte(path, val) { if (arguments.length === 1) { this._setConditional(this._currentPath, path, '$gte'); return this; } return this._setConditional(path, val, '$gte'); } lt(path, val) { if (arguments.length === 1) { this._setConditional(this._currentPath, path, '$lt'); return this; } return this._setConditional(path, val, '$lt'); } lte(path, val) { if (arguments.length === 1) { this._setConditional(this._currentPath, path, '$lte'); return this; } return this._setConditional(path, val, '$lte'); } ne(path, val) { if (arguments.length === 1) { this._setConditional(this._currentPath, path, '$ne'); return this; } return this._setConditional(path, val, '$ne'); } in(path, vals) { const p = arguments.length === 1 ? this._currentPath : path; const v = arguments.length === 1 ? path : vals; this.conditions[p] = { ...this.conditions[p], $in: Array.isArray(v) ? v : [v] }; return this; } nin(path, vals) { const p = arguments.length === 1 ? this._currentPath : path; const v = arguments.length === 1 ? path : vals; this.conditions[p] = { ...this.conditions[p], $nin: Array.isArray(v) ? v : [v] }; return this; } regex(path, val, options) { const p = (typeof path === 'string' && (val instanceof RegExp || typeof val === 'string')) ? path : this._currentPath; const v = (typeof path === 'string' && (val instanceof RegExp || typeof val === 'string')) ? val : path; const rgx = v instanceof RegExp ? v : new RegExp(v, options || 'i'); this.conditions[p] = { ...this.conditions[p], $regex: rgx }; return this; } mod(path, divisor, remainder) { if (arguments.length === 2) { remainder = divisor; divisor = path; path = this._currentPath; } this.conditions[path] = { $mod: [divisor, remainder] }; return this; } size(path, val) { const p = arguments.length === 1 ? this._currentPath : path; const v = arguments.length === 1 ? path : val; this.conditions[p] = { $size: v }; return this; } exists(path, val = true) { const p = arguments.length === 1 ? this._currentPath : path; const v = arguments.length === 1 ? path : val; this.conditions[p] = { $exists: v }; return this; } elemMatch(path, criteria) { this.conditions[path] = { $elemMatch: criteria }; return this; } all(path, values) { this.conditions[path] = { $all: values }; return this; } and(conditions) { if (!this.conditions.$and) this.conditions.$and = []; this.conditions.$and.push(...conditions); return this; } or(array) { this.conditions.$or = array; return this; } nor(array) { this.conditions.$nor = array; return this; } // ─── Result Modifiers ───────────────────────────────────────────────────── select(fields) { if (typeof fields === 'string') { fields.split(/\s+/).forEach(f => { if (!f) return; this._fields[f.replace(/^-/, '')] = f.startsWith('-') ? 0 : 1; }); } else if (typeof fields === 'object') { Object.assign(this._fields, fields); } return this; } sort(fields) { if (typeof fields === 'string') { fields.split(/\s+/).forEach(f => { if (!f) return; this._sort[f.replace(/^-/, '')] = f.startsWith('-') ? -1 : 1; }); } else if (typeof fields === 'object') { Object.assign(this._sort, fields); } return this; } limit(n) { this._limit = n; return this; } skip(n) { this._skip = n; return this; } lean(value = true) { this._lean = value; return this; } populate(path, select) { if (typeof path === 'string') this._populate.push({ path, select }); else if (Array.isArray(path)) path.forEach(p => this.populate(p)); else if (typeof path === 'object') this._populate.push(path); return this; } paginate(page = 1, limit = 10) { this._skip = (page - 1) * limit; this._limit = limit; return this; } transform(fn) { this._transform = fn; return this; } // ─── Query Options ───────────────────────────────────────────────────────── allowDiskUse(allow = true) { this._options.allowDiskUse = allow; return this; } batchSize(size) { this._batchSize = size; return this; } collation(value) { this._options.collation = value; return this; } comment(value) { this._comment = value; return this; } explain(value = true) { this._explain = value; return this; } hint(value) { this._hint = value; return this; } maxTimeMS(value) { this._maxTimeMS = value; return this; } mongooseOptions(opts) { Object.assign(this._mongooseOptions, opts); return this; } read(pref) { this._readPreference = pref; return this; } readConcern(level) { this._readConcern = level; return this; } session(session) { this._session = session; return this; } setOptions(opts) { Object.assign(this._options, opts); return this; } tailable(value = true) { this._tailable = value; return this; } writeConcern(concern) { this._writeConcern = concern; return this; } j(value = true) { this._writeConcern.j = value; return this; } w(val) { this._writeConcern.w = val; return this; } wtimeout(ms) { this._writeConcern.wtimeout = ms; return this; } // ─── Utility Methods ─────────────────────────────────────────────────────── $where(js) { this.conditions.$where = js; return this; } box(path, box) { this._geometry = { type: 'box', path, coordinates: box }; return this; } center(path, c) { this._geometry = { type: 'center', path, coordinates: c }; return this; } centerSphere(path, c) { this._geometry = { type: 'centerSphere', path, coordinates: c }; return this; } circle(path, c) { this._geometry = { type: 'circle', path, coordinates: c }; return this; } polygon(path, coords) { this._geometry = { type: 'polygon', path, coordinates: coords }; return this; } geometry(path, geo) { this._geometry = { type: 'geometry', path, coordinates: geo }; return this; } intersects(arg) { this._geoComparison = { $geoIntersects: arg }; return this; } near(path, coords) { // If called as .near(coords) after .where(path), use _currentPath if (coords === undefined) { this._geoComparison = { $near: path }; } else { this._currentPath = path; this._geoComparison = { $near: coords }; } return this; } nearSphere(path, coords) { if (coords === undefined) { this._geoComparison = { $nearSphere: path }; } else { this._currentPath = path; this._geoComparison = { $nearSphere: coords }; } return this; } maxDistance(value) { if (this._geoComparison) this._geoComparison.$maxDistance = value; return this; } within() { return this; } cursor() { throw new Error('Cursors are not supported in file-based storage'); } error(err) { this._error = err; return this; } orFail(err) { this._error = err || new Error('No document found'); return this; } get(path) { return path ? this._fields[path] : this.exec(); } getFilter() { return { ...this.conditions }; } getOptions() { return { ...this._options }; } getPopulatedPaths(){ return [...this._populate]; } getQuery() { return { ...this.conditions }; } getUpdate() { return this._update; } isPathSelectedInclusive(path) { return !!this._fields[path]; } merge(source) { Object.assign(this.conditions, source.conditions); Object.assign(this._fields, source._fields); Object.assign(this._sort, source._sort); this._limit = source._limit; this._skip = source._skip; return this; } projection(fields) { this._fields = fields; return this; } rand() { this._sort.$rand = 1; return this; } natural() { this._sort.$natural = 1; return this; } sanitizeProjection(fields) { const s = {}; for (const [k, v] of Object.entries(fields)) { if (typeof v === 'number' || typeof v === 'boolean') s[k] = v ? 1 : 0; } return s; } selected() { return Object.keys(this._fields).length > 0; } selectedExclusively() { return Object.values(this._fields).some(v => v === 0); } selectedInclusively() { return Object.values(this._fields).some(v => v === 1); } set(path, val) { if (typeof path === 'object') Object.assign(this._update || (this._update = {}), path); else { this._update = this._update || {}; this._update[path] = val; } return this; } setQuery(conditions) { this.conditions = conditions; return this; } setUpdate(update) { this._update = update; return this; } slice(path, val) { this._fields[path] = { $slice: val }; return this; } cast(model) { this.model = model; return this; } text(search) { this.conditions.$text = { $search: search }; return this; } expr(expression) { this.conditions.$expr = expression; return this; } jsonSchema(schema) { this.conditions.$jsonSchema = schema; return this; } meta(path) { this._fields[path] = { $meta: 'textScore' }; return this; } toConstructor() { const Q = function (criteria, opts) { Query.call(this, this.model, criteria); this.setOptions(opts || {}); }; Q.prototype = Object.create(Query.prototype); Q.prototype.constructor = Q; Q.prototype.model = this.model; return Q; } pre(method, fn) { if (!this._middleware.pre[method]) this._middleware.pre[method] = []; this._middleware.pre[method].push(fn); return this; } post(method, fn) { if (!this._middleware.post[method]) this._middleware.post[method] = []; this._middleware.post[method].push(fn); return this; } // ─── Async queries ───────────────────────────────────────────────────────── async countDocuments(conditions = {}) { const docs = await this.model._find({ ...this.conditions, ...conditions }); return docs.length; } async distinct(field) { const docs = await this.model._find(this.conditions); return [...new Set(docs.map(d => d[field]))]; } async estimatedDocumentCount() { return this.model.estimatedDocumentCount(); } // ─── Symbols ─────────────────────────────────────────────────────────────── [Symbol.asyncIterator]() { let index = 0; let documents; return { next: async () => { if (!documents) documents = await this.exec(); if (!Array.isArray(documents)) documents = documents ? [documents] : []; if (index < documents.length) return { value: documents[index++], done: false }; return { done: true }; } }; } get [Symbol.toStringTag]() { return 'Query'; } static get use$geoWithin() { return true; } } module.exports = { Query };