import * as plugins from '../tsmdb.plugins.js'; import type { Document, IStoredDocument } from '../types/interfaces.js'; import { QueryEngine } from './QueryEngine.js'; /** * Update engine for MongoDB-compatible update operations */ export class UpdateEngine { /** * Apply an update specification to a document * Returns the updated document or null if no update was applied */ static applyUpdate(document: IStoredDocument, update: Document, arrayFilters?: Document[]): IStoredDocument { // Check if this is an aggregation pipeline update if (Array.isArray(update)) { // Aggregation pipeline updates are not yet supported throw new Error('Aggregation pipeline updates are not yet supported'); } // Check if this is a replacement (no $ operators at top level) const hasOperators = Object.keys(update).some(k => k.startsWith('$')); if (!hasOperators) { // This is a replacement - preserve _id return { _id: document._id, ...update, }; } // Apply update operators const result = this.deepClone(document); for (const [operator, operand] of Object.entries(update)) { switch (operator) { case '$set': this.applySet(result, operand); break; case '$unset': this.applyUnset(result, operand); break; case '$inc': this.applyInc(result, operand); break; case '$mul': this.applyMul(result, operand); break; case '$min': this.applyMin(result, operand); break; case '$max': this.applyMax(result, operand); break; case '$rename': this.applyRename(result, operand); break; case '$currentDate': this.applyCurrentDate(result, operand); break; case '$setOnInsert': // Only applied during upsert insert, handled elsewhere break; case '$push': this.applyPush(result, operand, arrayFilters); break; case '$pop': this.applyPop(result, operand); break; case '$pull': this.applyPull(result, operand, arrayFilters); break; case '$pullAll': this.applyPullAll(result, operand); break; case '$addToSet': this.applyAddToSet(result, operand); break; case '$bit': this.applyBit(result, operand); break; default: throw new Error(`Unknown update operator: ${operator}`); } } return result; } /** * Apply $setOnInsert for upsert operations */ static applySetOnInsert(document: IStoredDocument, setOnInsert: Document): IStoredDocument { const result = this.deepClone(document); this.applySet(result, setOnInsert); return result; } /** * Deep clone a document */ private static deepClone(obj: any): any { if (obj === null || typeof obj !== 'object') { return obj; } if (obj instanceof plugins.bson.ObjectId) { return new plugins.bson.ObjectId(obj.toHexString()); } if (obj instanceof Date) { return new Date(obj.getTime()); } if (obj instanceof plugins.bson.Timestamp) { return new plugins.bson.Timestamp({ t: obj.high, i: obj.low }); } if (Array.isArray(obj)) { return obj.map(item => this.deepClone(item)); } const cloned: any = {}; for (const key of Object.keys(obj)) { cloned[key] = this.deepClone(obj[key]); } return cloned; } /** * Set a nested value */ private static setNestedValue(obj: any, path: string, value: any): void { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; // Handle array index notation const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/); if (arrayMatch) { const [, fieldName, indexStr] = arrayMatch; const index = parseInt(indexStr, 10); if (!(fieldName in current)) { current[fieldName] = []; } if (!current[fieldName][index]) { current[fieldName][index] = {}; } current = current[fieldName][index]; continue; } // Handle numeric index (array positional) const numIndex = parseInt(part, 10); if (!isNaN(numIndex) && Array.isArray(current)) { if (!current[numIndex]) { current[numIndex] = {}; } current = current[numIndex]; continue; } if (!(part in current) || current[part] === null) { current[part] = {}; } current = current[part]; } const lastPart = parts[parts.length - 1]; const numIndex = parseInt(lastPart, 10); if (!isNaN(numIndex) && Array.isArray(current)) { current[numIndex] = value; } else { current[lastPart] = value; } } /** * Get a nested value */ private static getNestedValue(obj: any, path: string): any { return QueryEngine.getNestedValue(obj, path); } /** * Delete a nested value */ private static deleteNestedValue(obj: any, path: string): void { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (!(part in current)) { return; } current = current[part]; } delete current[parts[parts.length - 1]]; } // ============================================================================ // Field Update Operators // ============================================================================ private static applySet(doc: any, fields: Document): void { for (const [path, value] of Object.entries(fields)) { this.setNestedValue(doc, path, this.deepClone(value)); } } private static applyUnset(doc: any, fields: Document): void { for (const path of Object.keys(fields)) { this.deleteNestedValue(doc, path); } } private static applyInc(doc: any, fields: Document): void { for (const [path, value] of Object.entries(fields)) { const current = this.getNestedValue(doc, path) || 0; if (typeof current !== 'number') { throw new Error(`Cannot apply $inc to non-numeric field: ${path}`); } this.setNestedValue(doc, path, current + (value as number)); } } private static applyMul(doc: any, fields: Document): void { for (const [path, value] of Object.entries(fields)) { const current = this.getNestedValue(doc, path) || 0; if (typeof current !== 'number') { throw new Error(`Cannot apply $mul to non-numeric field: ${path}`); } this.setNestedValue(doc, path, current * (value as number)); } } private static applyMin(doc: any, fields: Document): void { for (const [path, value] of Object.entries(fields)) { const current = this.getNestedValue(doc, path); if (current === undefined || this.compareValues(value, current) < 0) { this.setNestedValue(doc, path, this.deepClone(value)); } } } private static applyMax(doc: any, fields: Document): void { for (const [path, value] of Object.entries(fields)) { const current = this.getNestedValue(doc, path); if (current === undefined || this.compareValues(value, current) > 0) { this.setNestedValue(doc, path, this.deepClone(value)); } } } private static applyRename(doc: any, fields: Document): void { for (const [oldPath, newPath] of Object.entries(fields)) { const value = this.getNestedValue(doc, oldPath); if (value !== undefined) { this.deleteNestedValue(doc, oldPath); this.setNestedValue(doc, newPath as string, value); } } } private static applyCurrentDate(doc: any, fields: Document): void { for (const [path, spec] of Object.entries(fields)) { if (spec === true) { this.setNestedValue(doc, path, new Date()); } else if (typeof spec === 'object' && spec.$type === 'date') { this.setNestedValue(doc, path, new Date()); } else if (typeof spec === 'object' && spec.$type === 'timestamp') { this.setNestedValue(doc, path, new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: 0 })); } } } // ============================================================================ // Array Update Operators // ============================================================================ private static applyPush(doc: any, fields: Document, arrayFilters?: Document[]): void { for (const [path, spec] of Object.entries(fields)) { let arr = this.getNestedValue(doc, path); if (arr === undefined) { arr = []; this.setNestedValue(doc, path, arr); } if (!Array.isArray(arr)) { throw new Error(`Cannot apply $push to non-array field: ${path}`); } if (spec && typeof spec === 'object' && '$each' in spec) { // $push with modifiers let elements = (spec.$each as any[]).map(e => this.deepClone(e)); const position = spec.$position as number | undefined; const slice = spec.$slice as number | undefined; const sortSpec = spec.$sort; if (position !== undefined) { arr.splice(position, 0, ...elements); } else { arr.push(...elements); } if (sortSpec !== undefined) { if (typeof sortSpec === 'number') { arr.sort((a, b) => (a - b) * sortSpec); } else { // Sort by field(s) const entries = Object.entries(sortSpec as Document); arr.sort((a, b) => { for (const [field, dir] of entries) { const av = this.getNestedValue(a, field); const bv = this.getNestedValue(b, field); const cmp = this.compareValues(av, bv) * (dir as number); if (cmp !== 0) return cmp; } return 0; }); } } if (slice !== undefined) { if (slice >= 0) { arr.splice(slice); } else { arr.splice(0, arr.length + slice); } } } else { // Simple push arr.push(this.deepClone(spec)); } } } private static applyPop(doc: any, fields: Document): void { for (const [path, direction] of Object.entries(fields)) { const arr = this.getNestedValue(doc, path); if (!Array.isArray(arr)) { throw new Error(`Cannot apply $pop to non-array field: ${path}`); } if ((direction as number) === 1) { arr.pop(); } else { arr.shift(); } } } private static applyPull(doc: any, fields: Document, arrayFilters?: Document[]): void { for (const [path, condition] of Object.entries(fields)) { const arr = this.getNestedValue(doc, path); if (!Array.isArray(arr)) { continue; // Skip if not an array } if (typeof condition === 'object' && condition !== null && !Array.isArray(condition)) { // Condition is a query filter const hasOperators = Object.keys(condition).some(k => k.startsWith('$')); if (hasOperators) { // Filter using query operators const remaining = arr.filter(item => !QueryEngine.matches(item, condition)); arr.length = 0; arr.push(...remaining); } else { // Match documents with all specified fields const remaining = arr.filter(item => { if (typeof item !== 'object' || item === null) { return true; } return !Object.entries(condition).every(([k, v]) => { const itemVal = this.getNestedValue(item, k); return this.valuesEqual(itemVal, v); }); }); arr.length = 0; arr.push(...remaining); } } else { // Direct value match const remaining = arr.filter(item => !this.valuesEqual(item, condition)); arr.length = 0; arr.push(...remaining); } } } private static applyPullAll(doc: any, fields: Document): void { for (const [path, values] of Object.entries(fields)) { const arr = this.getNestedValue(doc, path); if (!Array.isArray(arr)) { continue; } if (!Array.isArray(values)) { throw new Error(`$pullAll requires an array argument`); } const valueSet = new Set(values.map(v => JSON.stringify(v))); const remaining = arr.filter(item => !valueSet.has(JSON.stringify(item))); arr.length = 0; arr.push(...remaining); } } private static applyAddToSet(doc: any, fields: Document): void { for (const [path, spec] of Object.entries(fields)) { let arr = this.getNestedValue(doc, path); if (arr === undefined) { arr = []; this.setNestedValue(doc, path, arr); } if (!Array.isArray(arr)) { throw new Error(`Cannot apply $addToSet to non-array field: ${path}`); } const existingSet = new Set(arr.map(v => JSON.stringify(v))); if (spec && typeof spec === 'object' && '$each' in spec) { for (const item of spec.$each as any[]) { const key = JSON.stringify(item); if (!existingSet.has(key)) { arr.push(this.deepClone(item)); existingSet.add(key); } } } else { const key = JSON.stringify(spec); if (!existingSet.has(key)) { arr.push(this.deepClone(spec)); } } } } private static applyBit(doc: any, fields: Document): void { for (const [path, operations] of Object.entries(fields)) { let current = this.getNestedValue(doc, path) || 0; if (typeof current !== 'number') { throw new Error(`Cannot apply $bit to non-numeric field: ${path}`); } for (const [op, value] of Object.entries(operations as Document)) { switch (op) { case 'and': current = current & (value as number); break; case 'or': current = current | (value as number); break; case 'xor': current = current ^ (value as number); break; } } this.setNestedValue(doc, path, current); } } // ============================================================================ // Helper Methods // ============================================================================ private static compareValues(a: any, b: any): number { if (a === b) return 0; if (a === null || a === undefined) return -1; if (b === null || b === undefined) return 1; if (typeof a === 'number' && typeof b === 'number') { return a - b; } if (a instanceof Date && b instanceof Date) { return a.getTime() - b.getTime(); } if (typeof a === 'string' && typeof b === 'string') { return a.localeCompare(b); } return String(a).localeCompare(String(b)); } private static valuesEqual(a: any, b: any): boolean { if (a === b) return true; if (a instanceof plugins.bson.ObjectId && b instanceof plugins.bson.ObjectId) { return a.equals(b); } if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { return JSON.stringify(a) === JSON.stringify(b); } return false; } }