302 lines
7.9 KiB
TypeScript
302 lines
7.9 KiB
TypeScript
import * as plugins from '../congodb.plugins.js';
|
|
import type { Document, IStoredDocument, ISortSpecification, ISortDirection } from '../types/interfaces.js';
|
|
|
|
// Import mingo Query class
|
|
import { Query } from 'mingo';
|
|
|
|
/**
|
|
* Query engine using mingo for MongoDB-compatible query matching
|
|
*/
|
|
export class QueryEngine {
|
|
/**
|
|
* Filter documents by a MongoDB query filter
|
|
*/
|
|
static filter(documents: IStoredDocument[], filter: Document): IStoredDocument[] {
|
|
if (!filter || Object.keys(filter).length === 0) {
|
|
return documents;
|
|
}
|
|
|
|
const query = new Query(filter);
|
|
return documents.filter(doc => query.test(doc));
|
|
}
|
|
|
|
/**
|
|
* Test if a single document matches a filter
|
|
*/
|
|
static matches(document: Document, filter: Document): boolean {
|
|
if (!filter || Object.keys(filter).length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const query = new Query(filter);
|
|
return query.test(document);
|
|
}
|
|
|
|
/**
|
|
* Find a single document matching the filter
|
|
*/
|
|
static findOne(documents: IStoredDocument[], filter: Document): IStoredDocument | null {
|
|
if (!filter || Object.keys(filter).length === 0) {
|
|
return documents[0] || null;
|
|
}
|
|
|
|
const query = new Query(filter);
|
|
for (const doc of documents) {
|
|
if (query.test(doc)) {
|
|
return doc;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Sort documents by a sort specification
|
|
*/
|
|
static sort(documents: IStoredDocument[], sort: ISortSpecification): IStoredDocument[] {
|
|
if (!sort) {
|
|
return documents;
|
|
}
|
|
|
|
// Normalize sort specification to array of [field, direction] pairs
|
|
const sortFields: Array<[string, number]> = [];
|
|
|
|
if (Array.isArray(sort)) {
|
|
for (const [field, direction] of sort) {
|
|
sortFields.push([field, this.normalizeDirection(direction)]);
|
|
}
|
|
} else {
|
|
for (const [field, direction] of Object.entries(sort)) {
|
|
sortFields.push([field, this.normalizeDirection(direction)]);
|
|
}
|
|
}
|
|
|
|
return [...documents].sort((a, b) => {
|
|
for (const [field, direction] of sortFields) {
|
|
const aVal = this.getNestedValue(a, field);
|
|
const bVal = this.getNestedValue(b, field);
|
|
|
|
const comparison = this.compareValues(aVal, bVal);
|
|
if (comparison !== 0) {
|
|
return comparison * direction;
|
|
}
|
|
}
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply projection to documents
|
|
*/
|
|
static project(documents: IStoredDocument[], projection: Document): Document[] {
|
|
if (!projection || Object.keys(projection).length === 0) {
|
|
return documents;
|
|
}
|
|
|
|
// Determine if this is inclusion or exclusion projection
|
|
const keys = Object.keys(projection);
|
|
const hasInclusion = keys.some(k => k !== '_id' && projection[k] === 1);
|
|
const hasExclusion = keys.some(k => k !== '_id' && projection[k] === 0);
|
|
|
|
// Can't mix inclusion and exclusion (except for _id)
|
|
if (hasInclusion && hasExclusion) {
|
|
throw new Error('Cannot mix inclusion and exclusion in projection');
|
|
}
|
|
|
|
return documents.map(doc => {
|
|
if (hasInclusion) {
|
|
// Inclusion projection
|
|
const result: Document = {};
|
|
|
|
// Handle _id
|
|
if (projection._id !== 0 && projection._id !== false) {
|
|
result._id = doc._id;
|
|
}
|
|
|
|
for (const key of keys) {
|
|
if (key === '_id') continue;
|
|
if (projection[key] === 1 || projection[key] === true) {
|
|
const value = this.getNestedValue(doc, key);
|
|
if (value !== undefined) {
|
|
this.setNestedValue(result, key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} else {
|
|
// Exclusion projection - start with copy and remove fields
|
|
const result = { ...doc };
|
|
|
|
for (const key of keys) {
|
|
if (projection[key] === 0 || projection[key] === false) {
|
|
this.deleteNestedValue(result, key);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get distinct values for a field
|
|
*/
|
|
static distinct(documents: IStoredDocument[], field: string, filter?: Document): any[] {
|
|
let docs = documents;
|
|
if (filter && Object.keys(filter).length > 0) {
|
|
docs = this.filter(documents, filter);
|
|
}
|
|
|
|
const values = new Set<any>();
|
|
for (const doc of docs) {
|
|
const value = this.getNestedValue(doc, field);
|
|
if (value !== undefined) {
|
|
if (Array.isArray(value)) {
|
|
// For arrays, add each element
|
|
for (const v of value) {
|
|
values.add(this.toComparable(v));
|
|
}
|
|
} else {
|
|
values.add(this.toComparable(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(values);
|
|
}
|
|
|
|
/**
|
|
* Normalize sort direction to 1 or -1
|
|
*/
|
|
private static normalizeDirection(direction: ISortDirection): number {
|
|
if (typeof direction === 'number') {
|
|
return direction > 0 ? 1 : -1;
|
|
}
|
|
if (direction === 'asc' || direction === 'ascending') {
|
|
return 1;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Get a nested value from an object using dot notation
|
|
*/
|
|
static getNestedValue(obj: any, path: string): any {
|
|
const parts = path.split('.');
|
|
let current = obj;
|
|
|
|
for (const part of parts) {
|
|
if (current === null || current === undefined) {
|
|
return undefined;
|
|
}
|
|
if (Array.isArray(current)) {
|
|
// Handle array access
|
|
const index = parseInt(part, 10);
|
|
if (!isNaN(index)) {
|
|
current = current[index];
|
|
} else {
|
|
// Get the field from all array elements
|
|
return current.map(item => this.getNestedValue(item, part)).flat();
|
|
}
|
|
} else {
|
|
current = current[part];
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Set a nested value in an object using dot notation
|
|
*/
|
|
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];
|
|
if (!(part in current)) {
|
|
current[part] = {};
|
|
}
|
|
current = current[part];
|
|
}
|
|
|
|
current[parts[parts.length - 1]] = value;
|
|
}
|
|
|
|
/**
|
|
* Delete a nested value from an object using dot notation
|
|
*/
|
|
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]];
|
|
}
|
|
|
|
/**
|
|
* Compare two values for sorting
|
|
*/
|
|
private static compareValues(a: any, b: any): number {
|
|
// Handle undefined/null
|
|
if (a === undefined && b === undefined) return 0;
|
|
if (a === undefined) return -1;
|
|
if (b === undefined) return 1;
|
|
if (a === null && b === null) return 0;
|
|
if (a === null) return -1;
|
|
if (b === null) return 1;
|
|
|
|
// Handle ObjectId
|
|
if (a instanceof plugins.bson.ObjectId && b instanceof plugins.bson.ObjectId) {
|
|
return a.toHexString().localeCompare(b.toHexString());
|
|
}
|
|
|
|
// Handle dates
|
|
if (a instanceof Date && b instanceof Date) {
|
|
return a.getTime() - b.getTime();
|
|
}
|
|
|
|
// Handle numbers
|
|
if (typeof a === 'number' && typeof b === 'number') {
|
|
return a - b;
|
|
}
|
|
|
|
// Handle strings
|
|
if (typeof a === 'string' && typeof b === 'string') {
|
|
return a.localeCompare(b);
|
|
}
|
|
|
|
// Handle booleans
|
|
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
return (a ? 1 : 0) - (b ? 1 : 0);
|
|
}
|
|
|
|
// Fall back to string comparison
|
|
return String(a).localeCompare(String(b));
|
|
}
|
|
|
|
/**
|
|
* Convert a value to a comparable form (for distinct)
|
|
*/
|
|
private static toComparable(value: any): any {
|
|
if (value instanceof plugins.bson.ObjectId) {
|
|
return value.toHexString();
|
|
}
|
|
if (value instanceof Date) {
|
|
return value.toISOString();
|
|
}
|
|
if (typeof value === 'object' && value !== null) {
|
|
return JSON.stringify(value);
|
|
}
|
|
return value;
|
|
}
|
|
}
|