import { isArray, Key, Comparator, isVanillaObject, comparable, equals } from "./utils"; export interface Operation { readonly keep: boolean; readonly done: boolean; reset(): void; next(item: TItem, key?: Key, owner?: any): void; } export type Tester = (item: any, key?: Key, owner?: any) => boolean; export interface NamedOperation { name: string; } export type OperationCreator = ( params: any, parentQuery: any, options: Options, name: string ) => Operation; type BasicValueQuery = { $eq?: TValue; $ne?: TValue; $lt?: TValue; $gt?: TValue; $lte?: TValue; $gte?: TValue; $in?: TValue[]; $nin?: TValue[]; $all?: TValue[]; $mod?: [number, number]; $exists?: boolean; $regex?: string | RegExp; $size?: number; $where?: ((this: TValue) => boolean) | string; $options?: "i" | "g" | "m" | "u"; $type?: Function; $not?: NestedQuery; $or?: NestedQuery[]; $nor?: NestedQuery[]; $and?: NestedQuery[]; }; type ArrayValueQuery = { $elemMatch?: Query; } & BasicValueQuery; type Unpacked = T extends (infer U)[] ? U : T; type ValueQuery = TValue extends Array ? ArrayValueQuery> : BasicValueQuery; type NotObject = string | number | Date | boolean | Array; type ShapeQuery = TItemSchema extends NotObject ? {} : { [k in keyof TItemSchema]?: TItemSchema[k] | ValueQuery }; type NestedQuery = ValueQuery & ShapeQuery; export type Query = | TItemSchema | RegExp | NestedQuery; /** * Walks through each value given the context - used for nested operations. E.g: * { "person.address": { $eq: "blarg" }} */ const walkKeyPathValues = ( item: any, keyPath: Key[], next: Tester, depth: number, key: Key, owner: any ): boolean => { const currentKey = keyPath[depth]; // if array, then try matching. Might fall through for cases like: // { $eq: [1, 2, 3] }, [ 1, 2, 3 ]. if (isArray(item) && isNaN(Number(currentKey))) { for (let i = 0, { length } = item; i < length; i++) { // if FALSE is returned, then terminate walker. For operations, this simply // means that the search critera was met. if (!walkKeyPathValues(item[i], keyPath, next, depth, i, item)) { return false; } } } if (depth === keyPath.length || item == null) { return next(item, key, owner); } return walkKeyPathValues( item[currentKey], keyPath, next, depth + 1, currentKey, item ); }; abstract class BaseOperation implements Operation { keep!: boolean; done!: boolean; constructor( readonly params: TParams, readonly owneryQuery: any, readonly options: Options ) { this.init(); } protected init() {} reset() { this.done = false; this.keep = false; } abstract next(item: any, key: Key, parent: any): void; } export abstract class NamedBaseOperation extends BaseOperation implements NamedOperation { constructor( params: TParams, owneryQuery: any, options: Options, readonly name: string ) { super(params, owneryQuery, options); } } abstract class GroupOperation extends BaseOperation { keep!: boolean; done!: boolean; constructor( params: any, owneryQuery: any, options: Options, public readonly children: Operation[] ) { super(params, owneryQuery, options); } /** */ reset() { this.keep = false; this.done = false; for (let i = 0, { length } = this.children; i < length; i++) { this.children[i].reset(); } } abstract next(item: any, key: Key, owner: any): void; /** */ protected childrenNext(item: any, key: Key, owner: any) { let done = true; let keep = true; for (let i = 0, { length } = this.children; i < length; i++) { const childOperation = this.children[i]; childOperation.next(item, key, owner); if (!childOperation.keep) { keep = false; } if (childOperation.done) { if (!childOperation.keep) { break; } } else { done = false; } } this.done = done; this.keep = keep; } } export abstract class NamedGroupOperation extends GroupOperation implements NamedOperation { constructor( params: any, owneryQuery: any, options: Options, children: Operation[], readonly name: string ) { super(params, owneryQuery, options, children); } } export class QueryOperation extends GroupOperation { /** */ next(item: TItem, key: Key, parent: any): void { this.childrenNext(item, key, parent); } } export class NestedOperation extends GroupOperation { constructor( readonly keyPath: Key[], params: any, owneryQuery: any, options: Options, children: Operation[] ) { super(params, owneryQuery, options, children); } /** */ next(item: any, key: Key, parent: any): void { walkKeyPathValues( item, this.keyPath, this._nextNestedValue, 0, key, parent ); } /** */ private _nextNestedValue = (value: any, key: Key, owner: any): boolean => { this.childrenNext(value, key, owner); return !this.done; }; } export const createTester = (a: any, compare: Comparator) => { if (a instanceof Function) { return a; } if (a instanceof RegExp) { return (b: any) => { const result = typeof b === "string" && a.test(b); a.lastIndex = 0; return result; }; } const comparableA = comparable(a); return (b: any) => compare(comparableA, comparable(b)); }; export class EqualsOperation extends BaseOperation { private _test!: Tester; init() { this._test = createTester(this.params, this.options.compare); } next(item: any, key: Key, parent: any) { if (!Array.isArray(parent) || parent.hasOwnProperty(key)) { if (this._test(item, key, parent)) { this.done = true; this.keep = true; } } } } export const createEqualsOperation = ( params: any, owneryQuery: any, options: Options ) => new EqualsOperation(params, owneryQuery, options); export class NopeOperation extends BaseOperation { next() { this.done = true; this.keep = false; } } export const numericalOperationCreator = ( createNumericalOperation: OperationCreator ) => (params: any, owneryQuery: any, options: Options, name: string) => { if (params == null) { return new NopeOperation(params, owneryQuery, options); } return createNumericalOperation(params, owneryQuery, options, name); }; export const numericalOperation = (createTester: (val: any) => Tester) => numericalOperationCreator( (params: any, owneryQuery: Query, options: Options) => { const typeofParams = typeof comparable(params); const test = createTester(params); return new EqualsOperation( (b: any) => { return typeof comparable(b) === typeofParams && test(b); }, owneryQuery, options ); } ); export type Options = { operations: { [identifier: string]: OperationCreator; }; compare: (a: any, b: any) => boolean; }; const createNamedOperation = ( name: string, params: any, parentQuery: any, options: Options ) => { const operationCreator = options.operations[name]; if (!operationCreator) { throw new Error(`Unsupported operation: ${name}`); } return operationCreator(params, parentQuery, options, name); }; export const containsOperation = (query: any) => { for (const key in query) { if (key.charAt(0) === "$") return true; } return false; }; const createNestedOperation = ( keyPath: Key[], nestedQuery: any, owneryQuery: any, options: Options ) => { if (containsOperation(nestedQuery)) { const [selfOperations, nestedOperations] = createQueryOperations( nestedQuery, options ); if (nestedOperations.length) { throw new Error( `Property queries must contain only operations, or exact objects.` ); } return new NestedOperation( keyPath, nestedQuery, owneryQuery, options, selfOperations ); } return new NestedOperation(keyPath, nestedQuery, owneryQuery, options, [ new EqualsOperation(nestedQuery, owneryQuery, options) ]); }; export const createQueryOperation = ( query: Query, owneryQuery: any = null, { compare, operations }: Partial = {} ): QueryOperation => { const options = { compare: compare || equals, operations: Object.assign({}, operations || {}) }; const [selfOperations, nestedOperations] = createQueryOperations( query, options ); const ops: NestedOperation[] = []; if (selfOperations.length) { ops.push( new NestedOperation([], query, owneryQuery, options, selfOperations) ); } ops.push(...nestedOperations); if (ops.length === 1) { return ops[0]; } return new QueryOperation(query, owneryQuery, options, ops); }; const createQueryOperations = ( query: any, options: Options ): [Operation[], NestedOperation[]] => { const selfOperations: Operation[] = []; const nestedOperations: NestedOperation[] = []; if (!isVanillaObject(query)) { selfOperations.push(new EqualsOperation(query, query, options)); return [selfOperations, nestedOperations]; } for (const key in query) { if (key.charAt(0) === "$") { const op = createNamedOperation(key, query[key], query, options); // probably just a flag for another operation (like $options) if (op != null) { selfOperations.push(op); } } else { nestedOperations.push( createNestedOperation(key.split("."), query[key], query, options) ); } } return [selfOperations, nestedOperations]; }; export const createOperationTester = (operation: Operation): Tester => (item: TItem, key?: Key, owner?: any): boolean => { operation.reset(); operation.next(item, key, owner); return operation.keep; }; export const createQueryTester = ( query: Query, options: Partial = {} ): Tester => { return createOperationTester( createQueryOperation(query, null, options) ); };