File size: 5,143 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | // src/query.ts
//
// A small, zero-dependency "jq / MongoDB-ish" query engine over JSON documents:
// - path get/set/projection (dot + bracket index: "a.b[0].c")
// - filter predicates (Mongo-like: { age: { $gt: 18 }, $or: [...] })
// - sort / skip / limit
export type Doc = unknown;
type Path = string | readonly (string | number)[];
function tokenizePath(path: Path): (string | number)[] {
if (Array.isArray(path)) return [...path];
const tokens: (string | number)[] = [];
for (const seg of String(path).split('.')) {
let s = seg;
while (s.length) {
const m = s.match(/^([^[]*)\[(\d+)\](.*)$/);
if (m) {
if (m[1]) tokens.push(m[1]);
tokens.push(Number(m[2]));
s = m[3]!;
} else {
tokens.push(s);
s = '';
}
}
}
return tokens;
}
export function getPath(doc: Doc, path: Path): unknown {
let cur: unknown = doc;
for (const t of tokenizePath(path)) {
if (cur === null || cur === undefined) return undefined;
cur = (cur as Record<string | number, unknown>)[t];
}
return cur;
}
export function setPath(obj: Doc, path: Path, value: unknown): Doc {
const tokens = tokenizePath(path);
let cur = obj as Record<string | number, unknown>;
for (let i = 0; i < tokens.length - 1; i++) {
const t = tokens[i]!;
if (cur[t] === null || cur[t] === undefined || typeof cur[t] !== 'object') {
cur[t] = typeof tokens[i + 1] === 'number' ? [] : {};
}
cur = cur[t] as Record<string | number, unknown>;
}
cur[tokens[tokens.length - 1]!] = value;
return obj;
}
/** Keep only the given paths (inclusion). Returns a new object. */
export function project(doc: Doc, paths?: readonly string[]): Doc {
if (!paths || !paths.length) return doc;
const out: Record<string, unknown> = {};
for (const p of paths) {
const v = getPath(doc, p);
if (v !== undefined) setPath(out, p, v);
}
return out;
}
// --- filter --------------------------------------------------------------
type Cond = unknown;
function matchCond(val: unknown, cond: Cond): boolean {
if (cond === null || typeof cond !== 'object' || cond instanceof RegExp) {
if (cond instanceof RegExp) {
// A caller-supplied RegExp with the global/sticky flag is stateful:
// .test() advances lastIndex. Reset it so every document is tested from
// the start instead of alternating match/miss across documents.
cond.lastIndex = 0;
return typeof val === 'string' && cond.test(val);
}
return val === cond;
}
for (const op of Object.keys(cond as Record<string, unknown>)) {
const arg = (cond as Record<string, unknown>)[op];
switch (op) {
case '$eq':
if (val !== arg) return false;
break;
case '$ne':
if (val === arg) return false;
break;
case '$gt':
if (!((val as number) > (arg as number))) return false;
break;
case '$gte':
if (!((val as number) >= (arg as number))) return false;
break;
case '$lt':
if (!((val as number) < (arg as number))) return false;
break;
case '$lte':
if (!((val as number) <= (arg as number))) return false;
break;
case '$in':
if (!Array.isArray(arg) || !arg.includes(val)) return false;
break;
case '$nin':
if (!Array.isArray(arg) || arg.includes(val)) return false;
break;
case '$regex': {
const re =
arg instanceof RegExp ? arg : Array.isArray(arg) ? new RegExp(arg[0] as string, arg[1] as string | undefined) : new RegExp(arg as string);
if (typeof val !== 'string') return false;
// Reset a stateful (global/sticky) RegExp so a reused instance does not
// carry lastIndex over from the previous document.
re.lastIndex = 0;
if (!re.test(val)) return false;
break;
}
case '$exists':
if ((val !== undefined) !== !!arg) return false;
break;
case '$contains':
if (!Array.isArray(val) || !val.includes(arg)) return false;
break;
case '$type':
if (typeof val !== arg) return false;
break;
default:
return false;
}
}
return true;
}
/** Does `doc` satisfy the Mongo-like `filter`? */
export function match(doc: Doc, filter?: Record<string, unknown> | null): boolean {
if (!filter || Object.keys(filter).length === 0) return true;
for (const key of Object.keys(filter)) {
const cond = filter[key];
if (key === '$and') {
if (!Array.isArray(cond) || !cond.every((f) => match(doc, f as Record<string, unknown>))) return false;
} else if (key === '$or') {
if (!Array.isArray(cond) || !cond.some((f) => match(doc, f as Record<string, unknown>))) return false;
} else if (key === '$nor') {
if (!Array.isArray(cond) || cond.some((f) => match(doc, f as Record<string, unknown>))) return false;
} else if (key === '$not') {
if (match(doc, cond as Record<string, unknown>)) return false;
} else {
if (!matchCond(getPath(doc, key), cond)) return false;
}
}
return true;
}
|