Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
node-version: [18.x, 20.x]
node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
Expand Down
69 changes: 40 additions & 29 deletions src/core/_internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,27 @@ export enum OpType {
WINDOW = "window"
}

// cached values to avoid repeated Object.values() calls
const OP_TYPE_VALUES: OpType[] = Object.values(OpType);

/**
* The `Context` class is a utility for managing and organizing operators of various types.
* It provides methods to initialize, merge, and retrieve operators, as well as add specific
* types of operators to the context.
*/
export class Context {
#operators: Record<string, Record<string, Callback>> = Object.fromEntries(
Object.values(OpType).map(k => [k, {}])
);

private constructor() {}
#operators: Record<string, Record<string, Callback>>;

private constructor() {
this.#operators = {
[OpType.ACCUMULATOR]: {},
[OpType.EXPRESSION]: {},
[OpType.PIPELINE]: {},
[OpType.PROJECTION]: {},
[OpType.QUERY]: {},
[OpType.WINDOW]: {}
};
}

static init(
ops: {
Expand All @@ -193,7 +203,8 @@ export class Context {
): Context {
const ctx = new Context();
// ensure all operator types are initialized
for (const [type, operators] of Object.entries(ops)) {
for (const type of Object.keys(ops)) {
const operators = (ops as Record<string, Record<string, Callback>>)[type];
if (ctx.#operators[type] && operators) {
// direct assignment since operators are empty at init time
ctx.#operators[type] = { ...operators };
Expand All @@ -208,7 +219,7 @@ export class Context {
if (ctx.length === 1) return ctx[0];
const newCtx = new Context();
for (const context of ctx) {
for (const type of Object.values(OpType)) {
for (const type of OP_TYPE_VALUES) {
newCtx.addOps(type, context.#operators[type]);
}
}
Expand Down Expand Up @@ -282,8 +293,7 @@ export function evalExpr(obj: Any, expr: Any, options: Options): Any {
return computeExpression(obj, expr, copts);
}

const SYSTEM_VARS = ["$$ROOT", "$$CURRENT", "$$REMOVE", "$$NOW"] as const;
type SystemVar = (typeof SYSTEM_VARS)[number];
const SYSTEM_VARS = new Set(["$$ROOT", "$$CURRENT", "$$REMOVE", "$$NOW"]);

/** Computes the value of the expr given for the object. */
function computeExpression(obj: Any, expr: Any, options: ComputeOptions): Any {
Expand All @@ -292,7 +302,7 @@ function computeExpression(obj: Any, expr: Any, options: ComputeOptions): Any {
// we check and process them in that order.
//
// if expr begins only a single "$", then it is a path to a field on the object.
if (isString(expr) && expr.length > 0 && expr[0] === "$") {
if (isString(expr) && expr.length > 0 && expr.charCodeAt(0) === 0x24 /*$*/) {
// we return redact variables as literals
if (expr === "$$KEEP" || expr === "$$PRUNE" || expr === "$$DESCEND")
return expr;
Expand All @@ -301,11 +311,12 @@ function computeExpression(obj: Any, expr: Any, options: ComputeOptions): Any {
let ctx = options.local.root;

// handle selectors with explicit prefix
const arr = expr.split(".");
if (SYSTEM_VARS.includes(arr[0] as SystemVar)) {
const dotIdx = expr.indexOf(".");
const prefix = dotIdx === -1 ? expr : expr.substring(0, dotIdx);
if (SYSTEM_VARS.has(prefix)) {
// set 'root' only the first time it is required to be used for all subsequent calls
// if it already available on the options, it will be used
switch (arr[0] as SystemVar) {
switch (prefix) {
case "$$ROOT":
break;
case "$$CURRENT":
Expand All @@ -318,8 +329,8 @@ function computeExpression(obj: Any, expr: Any, options: ComputeOptions): Any {
ctx = new Date(options.now);
break;
}
expr = expr.slice(arr[0].length + 1); // +1 for '.'
} else if (arr[0].slice(0, 2) === "$$") {
expr = dotIdx === -1 ? "" : expr.substring(dotIdx + 1);
} else if (prefix.length >= 2 && prefix.charCodeAt(1) === 0x24 /*$*/) {
// handle user-defined variables
ctx = Object.assign(
{},
Expand All @@ -331,12 +342,12 @@ function computeExpression(obj: Any, expr: Any, options: ComputeOptions): Any {
options?.local?.variables
);
// the variable name
const name = arr[0].slice(2);
const name = prefix.substring(2);
assert(has(ctx as AnyObject, name), `Use of undefined variable: ${name}`);
expr = expr.slice(2);
expr = expr.substring(2);
} else {
// 'expr' is a path to a field on the object.
expr = expr.slice(1);
expr = expr.substring(1);
}

return expr === "" ? ctx : resolve(ctx as ArrayOrObject, expr as string);
Expand All @@ -348,18 +359,18 @@ function computeExpression(obj: Any, expr: Any, options: ComputeOptions): Any {
}

if (isObject(expr)) {
const keys = Object.keys(expr);
// if object represents an operator expression, there should only be a single key
if (isOperator(keys[0])) {
assert(
keys.length === 1,
`Expression must contain a single operator. got [${keys.join(",")}]`
);
return computeOperator(obj, expr[keys[0]], keys[0], options);
}
const result: AnyObject = {};
const elems = Object.entries(expr);
for (const [key, val] of elems) {
// if object represents an operator expression, there should only be a single key
if (isOperator(key)) {
assert(
elems.length === 1,
`Expression must contain a single operator. got [${Object.keys(expr).join(",")}]`
);
return computeOperator(obj, val, key, options);
}
result[key] = computeExpression(obj, val, options);
for (let i = 0; i < keys.length; i++) {
result[keys[i]] = computeExpression(obj, expr[keys[i]], options);
}
return result;
}
Expand Down
8 changes: 7 additions & 1 deletion src/operators/expression/set/setDifference.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { evalExpr } from "../../../core/_internal";
import { Any, AnyObject, Options } from "../../../types";
import { assert, HashMap, isArray, isNil } from "../../../util";
import { assert, HashMap, isArray, isNil, isPrimitive } from "../../../util";
import { errExpectArray } from "../_internal";

const OP = "$setDifference";
Expand All @@ -23,6 +23,12 @@ export const $setDifference = (
ok &&= isArray(v);
}
if (!ok) return errExpectArray(foe, `${OP} arguments`);

// fast path: use native Set.difference() when all elements are primitives
if (args[0].every(isPrimitive) && args[1].every(isPrimitive)) {
return Array.from(new Set(args[0]).difference(new Set(args[1])));
}

const m = HashMap.init();
args[0].forEach(v => m.set(v, true));
args[1].forEach(v => m.delete(v));
Expand Down
12 changes: 11 additions & 1 deletion src/operators/expression/set/setEquals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { evalExpr } from "../../../core/_internal";
import { Any, AnyObject, Options } from "../../../types";
import { assert, HashMap, isArray } from "../../../util";
import { assert, HashMap, isArray, isPrimitive } from "../../../util";
import { errExpectArray } from "../_internal";

/**
Expand All @@ -17,6 +17,16 @@ export const $setEquals = (
// all args must be arrays (not null/undefined)
if (!args.every(isArray)) return errExpectArray(foe, "$setEquals arguments");

// fast path: use native Set when all elements across all arrays are primitives
if (args.every(arr => arr.every(isPrimitive))) {
const first = new Set(args[0]);
for (let i = 1; i < args.length; i++) {
const other = new Set(args[i]);
if (first.size !== other.size || !first.isSubsetOf(other)) return false;
}
return true;
}

// store a unique number for each unique item. repeated values may be overriden but that's okay.
const map = HashMap.init<Any, number>();
args[0].every((v, i) => map.set(v, i));
Expand Down
8 changes: 7 additions & 1 deletion src/operators/expression/set/setIsSubset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { evalExpr } from "../../../core/_internal";
import { Any, AnyObject, Options } from "../../../types";
import { assert, HashMap, isArray } from "../../../util";
import { assert, HashMap, isArray, isPrimitive } from "../../../util";
import { errExpectArray } from "../_internal";

const OP = "$setIsSubset";
Expand All @@ -20,6 +20,12 @@ export const $setIsSubset = (
return errExpectArray(options.failOnError, `${OP} arguments`);

const [first, second] = args;

// fast path: use native Set.isSubsetOf() when all elements are primitives
if (first.every(isPrimitive) && second.every(isPrimitive)) {
return new Set(first).isSubsetOf(new Set(second));
}

const map = HashMap.init<Any, number>();
const set = new Set<number>();

Expand Down
18 changes: 10 additions & 8 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
} from "./types";
import { assert, cloneDeep, isObject, isOperator, normalize } from "./util";

const TOP_LEVEL_RE = /^\$(and|or|nor|expr|jsonSchema)$/;
const TOP_LEVEL_OPS = new Set(["$and", "$or", "$nor", "$expr", "$jsonSchema"]);

/**
* Represents a query object used to filter and match documents based on specified criteria.
Expand Down Expand Up @@ -52,23 +52,25 @@ export class Query<T = AnyObject> {
);

const whereOperator: { field?: string; expr?: Any } = {};
const conditions = Object.entries(this.#condition);
for (const [field, expr] of conditions) {
const conditions = Object.keys(this.#condition);
for (let ci = 0; ci < conditions.length; ci++) {
const field = conditions[ci];
const expr = (this.#condition as AnyObject)[field];
if ("$where" === field) {
assert(
this.#options.scriptEnabled,
"$where operator requires 'scriptEnabled' option to be true."
);
Object.assign(whereOperator, { field: field, expr: expr });
} else if (TOP_LEVEL_RE.test(field)) {
} else if (TOP_LEVEL_OPS.has(field)) {
this.processOperator(field, field, expr);
} else {
// normalize expression
assert(!isOperator(field), `unknown top level operator: ${field}`);
for (const [operator, val] of Object.entries(
normalize(expr) as AnyObject
)) {
this.processOperator(field, operator, val);
const normalized = normalize(expr) as AnyObject;
const normKeys = Object.keys(normalized);
for (let ni = 0; ni < normKeys.length; ni++) {
this.processOperator(field, normKeys[ni], normalized[normKeys[ni]]);
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/set-methods.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Type declarations for native Set methods (ES2025).
* Internalized here so consumers of the library do not need to add
* "esnext.collection" to their own tsconfig lib.
*/
interface Set<T> {
intersection(other: ReadonlySetLike<T>): Set<T>;
difference(other: ReadonlySetLike<T>): Set<T>;
isSubsetOf(other: ReadonlySetLike<T>): boolean;
}

interface ReadonlySetLike<T> {
has(value: T): boolean;
keys(): IterableIterator<T>;
readonly size: number;
}
10 changes: 6 additions & 4 deletions src/util/_hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,13 @@ function hashObject(
if (seen.has(obj)) return Tag.Cycle;
seen.add(obj);

const keys = Object.keys(obj).sort();
const keys = Object.keys(obj);
// sort in-place to avoid extra allocation (keys array is already a fresh copy)
keys.sort();
let h = hashString(obj?.constructor?.name);
for (const k of keys) {
h = mix(h, hashString(k));
h = mix(h, internalHash(obj[k], seen));
for (let i = 0; i < keys.length; i++) {
h = mix(h, hashString(keys[i]));
h = mix(h, internalHash(obj[keys[i]], seen));
}

seen.delete(obj);
Expand Down
Loading
Loading