507 lines
15 KiB
TypeScript
507 lines
15 KiB
TypeScript
import * as plugins from '../congodb.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;
|
|
}
|
|
}
|