import {
isArray,
Key,
Comparator,
isVanillaObject,
comparable,
equals
} from "./utils";
export interface Operation<TItem> {
readonly keep: boolean;
readonly done: boolean;
propop: boolean;
reset();
next(item: TItem, key?: Key, owner?: any, root?: boolean);
}
export type Tester = (
item: any,
key?: Key,
owner?: any,
root?: boolean
) => boolean;
export interface NamedOperation {
name: string;
}
export type OperationCreator<TItem> = (
params: any,
parentQuery: any,
options: Options,
name: string
) => Operation<TItem>;
type BasicValueQuery<TValue> = {
$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, obj: TValue) => boolean) | string;
$options?: "i" | "g" | "m" | "u";
$type?: Function;
$not?: NestedQuery<TValue>;
$or?: NestedQuery<TValue>[];
$nor?: NestedQuery<TValue>[];
$and?: NestedQuery<TValue>[];
};
type ArrayValueQuery<TValue> = {
$elemMatch?: Query<TValue>;
} & BasicValueQuery<TValue>;
type Unpacked<T> = T extends (infer U)[] ? U : T;
type ValueQuery<TValue> = TValue extends Array<any>
? ArrayValueQuery<Unpacked<TValue>>
: BasicValueQuery<TValue>;
type NotObject = string | number | Date | boolean | Array<any>;
type ShapeQuery<TItemSchema> = TItemSchema extends NotObject
? {}
: { [k in keyof TItemSchema]?: TItemSchema[k] | ValueQuery<TItemSchema[k]> };
type NestedQuery<TItemSchema> = ValueQuery<TItemSchema> &
ShapeQuery<TItemSchema>;
export type Query<TItemSchema> =
| TItemSchema
| RegExp
| NestedQuery<TItemSchema>;
/**
* 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
) => {
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, depth === 0);
}
return walkKeyPathValues(
item[currentKey],
keyPath,
next,
depth + 1,
currentKey,
item
);
};
export abstract class BaseOperation<TParams, TItem = any>
implements Operation<TItem> {
keep: boolean;
done: boolean;
abstract propop: boolean;
constructor(
readonly params: TParams,
readonly owneryQuery: any,
readonly options: Options,
readonly name?: string
) {
this.init();
}
protected init() {}
reset() {
this.done = false;
this.keep = false;
}
abstract next(item: any, key: Key, parent: any, root: boolean);
}
abstract class GroupOperation extends BaseOperation<any> {
keep: boolean;
done: boolean;
constructor(
params: any,
owneryQuery: any,
options: Options,
public readonly children: Operation<any>[]
) {
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, root: boolean);
/**
*/
protected childrenNext(item: any, key: Key, owner: any, root: boolean) {
let done = true;
let keep = true;
for (let i = 0, { length } = this.children; i < length; i++) {
const childOperation = this.children[i];
if (!childOperation.done) {
childOperation.next(item, key, owner, root);
}
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 {
abstract propop: boolean;
constructor(
params: any,
owneryQuery: any,
options: Options,
children: Operation<any>[],
readonly name: string
) {
super(params, owneryQuery, options, children);
}
}
export class QueryOperation<TItem> extends GroupOperation {
readonly propop = true;
/**
*/
next(item: TItem, key: Key, parent: any, root: boolean) {
this.childrenNext(item, key, parent, root);
}
}
export class NestedOperation extends GroupOperation {
readonly propop = true;
constructor(
readonly keyPath: Key[],
params: any,
owneryQuery: any,
options: Options,
children: Operation<any>[]
) {
super(params, owneryQuery, options, children);
}
/**
*/
next(item: any, key: Key, parent: any) {
walkKeyPathValues(
item,
this.keyPath,
this._nextNestedValue,
0,
key,
parent
);
}
/**
*/
private _nextNestedValue = (
value: any,
key: Key,
owner: any,
root: boolean
) => {
this.childrenNext(value, key, owner, root);
return !this.done;
};
}
export const createTester = (a, compare: Comparator) => {
if (a instanceof Function) {
return a;
}
if (a instanceof RegExp) {
return b => {
const result = typeof b === "string" && a.test(b);
a.lastIndex = 0;
return result;
};
}
const comparableA = comparable(a);
return b => compare(comparableA, comparable(b));
};
export class EqualsOperation<TParam> extends BaseOperation<TParam> {
readonly propop = true;
private _test: Tester;
init() {
this._test = createTester(this.params, this.options.compare);
}
next(item, 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<TParam> extends BaseOperation<TParam> {
readonly propop = true;
next() {
this.done = true;
this.keep = false;
}
}
export const numericalOperationCreator = (
createNumericalOperation: OperationCreator<any>
) => (params: any, owneryQuery: any, options: Options, name: string) => {
if (params == null) {
return new NopeOperation(params, owneryQuery, options, name);
}
return createNumericalOperation(params, owneryQuery, options, name);
};
export const numericalOperation = (createTester: (any) => Tester) =>
numericalOperationCreator(
(params: any, owneryQuery: Query<any>, options: Options, name: string) => {
const typeofParams = typeof comparable(params);
const test = createTester(params);
return new EqualsOperation(
b => {
return typeof comparable(b) === typeofParams && test(b);
},
owneryQuery,
options,
name
);
}
);
export type Options = {
operations: {
[identifier: string]: OperationCreator<any>;
};
compare: (a, b) => boolean;
};
const createNamedOperation = (
name: string,
params: any,
parentQuery: any,
options: Options
) => {
const operationCreator = options.operations[name];
if (!operationCreator) {
throwUnsupportedOperation(name);
}
return operationCreator(params, parentQuery, options, name);
};
const throwUnsupportedOperation = (name: string) => {
throw new Error(`Unsupported operation: ${name}`);
};
export const containsOperation = (query: any, options: Options) => {
for (const key in query) {
if (options.operations.hasOwnProperty(key) || key.charAt(0) === "$")
return true;
}
return false;
};
const createNestedOperation = (
keyPath: Key[],
nestedQuery: any,
parentKey: string,
owneryQuery: any,
options: Options
) => {
if (containsOperation(nestedQuery, options)) {
const [selfOperations, nestedOperations] = createQueryOperations(
nestedQuery,
parentKey,
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 = <TItem, TSchema = TItem>(
query: Query<TSchema>,
owneryQuery: any = null,
{ compare, operations }: Partial<Options> = {}
): QueryOperation<TItem> => {
const options = {
compare: compare || equals,
operations: Object.assign({}, operations || {})
};
const [selfOperations, nestedOperations] = createQueryOperations(
query,
null,
options
);
const ops = [];
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,
parentKey: string,
options: Options
) => {
const selfOperations = [];
const nestedOperations = [];
if (!isVanillaObject(query)) {
selfOperations.push(new EqualsOperation(query, query, options));
return [selfOperations, nestedOperations];
}
for (const key in query) {
if (options.operations.hasOwnProperty(key)) {
const op = createNamedOperation(key, query[key], query, options);
if (op) {
if (!op.propop && parentKey && !options.operations[parentKey]) {
throw new Error(
`Malformed query. ${key} cannot be matched against property.`
);
}
}
// probably just a flag for another operation (like $options)
if (op != null) {
selfOperations.push(op);
}
} else if (key.charAt(0) === "$") {
throwUnsupportedOperation(key);
} else {
nestedOperations.push(
createNestedOperation(key.split("."), query[key], key, query, options)
);
}
}
return [selfOperations, nestedOperations];
};
export const createOperationTester = <TItem>(operation: Operation<TItem>) => (
item: TItem,
key?: Key,
owner?: any
) => {
operation.reset();
operation.next(item, key, owner);
return operation.keep;
};
export const createQueryTester = <TItem, TSchema = TItem>(
query: Query<TSchema>,
options: Partial<Options> = {}
) => {
return createOperationTester(
createQueryOperation<TItem, TSchema>(query, null, options)
);
};