Blog/wwwroot/jsonql-js/evaluate.js 12.1 K · 377 lines · raw · history

1 import { SqlEvaluationError } from './errors.js';
2 import { builtins } from './functions.js';
3
4 /**
5 * Evaluates a parsed expression AST against a JSON context object.
6 *
7 * Implements SQL three-valued logic: comparisons and arithmetic against `null`
8 * propagate `null` rather than throwing or coercing, except `IS [NOT] NULL`
9 * which always returns a real boolean.
10 *
11 * @param {import('./parser.js').Expr} node
12 * @param {Record<string, any>} context
13 * @param {{functions?: Record<string, (...args: any[]) => any>}} [options]
14 * @returns {any}
15 */
16 export function evaluate(node, context, options = {}) {
17 const functions = { ...builtins, ...uppercaseKeys(options.functions) };
18 return evalNode(node, context, functions);
19 }
20
21 /**
22 * @param {import('./parser.js').Expr} node
23 * @param {Record<string, any>} ctx
24 * @param {Record<string, (...args: any[]) => any>} functions
25 * @returns {any}
26 */
27 function evalNode(node, ctx, functions) {
28 switch (node.type) {
29 case 'Literal':
30 return node.value;
31 case 'ArrayLiteral':
32 return node.elements.map((e) => evalNode(e, ctx, functions));
33 case 'ObjectLiteral': {
34 /** @type {Record<string, any>} */
35 const out = {};
36 for (const prop of node.properties) {
37 out[prop.key] = evalNode(prop.value, ctx, functions);
38 }
39 return out;
40 }
41 case 'Identifier':
42 case 'Member':
43 case 'Index':
44 case 'Unnest':
45 return evalPathish(node, ctx, functions).value;
46 case 'Call':
47 return evalCall(node, ctx, functions);
48 case 'Negate': {
49 const v = evalNode(node.expr, ctx, functions);
50 if (v === null) return null;
51 if (typeof v !== 'number') throw new SqlEvaluationError(`Cannot negate ${typeName(v)}`);
52 return -v;
53 }
54 case 'Not': {
55 const v = evalNode(node.expr, ctx, functions);
56 if (v === null) return null;
57 if (typeof v !== 'boolean') throw new SqlEvaluationError(`NOT requires a boolean, got ${typeName(v)}`);
58 return !v;
59 }
60 case 'Logical':
61 return evalLogical(node, ctx, functions);
62 case 'Comparison':
63 return evalComparison(node, ctx, functions);
64 case 'IsNull': {
65 const v = evalNode(node.expr, ctx, functions);
66 const isNull = v === null;
67 return node.negate ? !isNull : isNull;
68 }
69 case 'Like':
70 return evalLike(node, ctx, functions);
71 case 'InList':
72 return evalInList(node, ctx, functions);
73 case 'InArray':
74 return evalInArray(node, ctx, functions);
75 case 'Binary':
76 return evalBinary(node, ctx, functions);
77 default:
78 throw new SqlEvaluationError(`Unknown AST node type: ${/** @type {any} */ (node).type}`);
79 }
80 }
81
82 /**
83 * Path expressions (`Identifier`/`Member`/`Index`/`Unnest`) thread a `mapped` flag: once an
84 * `[]` unnest is hit, every subsequent `.prop`/`[i]` in the chain maps over the resulting array
85 * instead of applying to it directly (e.g. `items[].sku` -> `items.map(i => i.sku)`).
86 * @param {import('./parser.js').Expr} node
87 * @param {Record<string, any>} ctx
88 * @param {Record<string, (...args: any[]) => any>} functions
89 * @returns {{value: any, mapped: boolean}}
90 */
91 function evalPathish(node, ctx, functions) {
92 switch (node.type) {
93 case 'Identifier': {
94 const v = ctx == null ? null : ctx[node.name];
95 return { value: v === undefined ? null : v, mapped: false };
96 }
97 case 'Member': {
98 const base = evalPathish(node.object, ctx, functions);
99 if (base.mapped) {
100 const arr = Array.isArray(base.value) ? base.value : [];
101 return { value: arr.map((item) => memberGet(item, node.property)), mapped: true };
102 }
103 return { value: memberGet(base.value, node.property), mapped: false };
104 }
105 case 'Index': {
106 const base = evalPathish(node.object, ctx, functions);
107 const idx = evalNode(node.index, ctx, functions);
108 if (base.mapped) {
109 const arr = Array.isArray(base.value) ? base.value : [];
110 return { value: arr.map((item) => indexGet(item, idx)), mapped: true };
111 }
112 return { value: indexGet(base.value, idx), mapped: false };
113 }
114 case 'Unnest': {
115 const base = evalPathish(node.object, ctx, functions);
116 if (base.mapped) {
117 const arr = Array.isArray(base.value) ? base.value : [];
118 const flattened = arr.flatMap((v) => {
119 if (v === null || v === undefined) return [];
120 if (Array.isArray(v)) return v;
121 throw new SqlEvaluationError('Cannot unnest a non-array value');
122 });
123 return { value: flattened, mapped: true };
124 }
125 if (base.value === null || base.value === undefined) return { value: [], mapped: true };
126 if (!Array.isArray(base.value)) throw new SqlEvaluationError('Cannot unnest a non-array value');
127 return { value: base.value, mapped: true };
128 }
129 default:
130 return { value: evalNode(node, ctx, functions), mapped: false };
131 }
132 }
133
134 /** @param {any} obj @param {string} prop */
135 function memberGet(obj, prop) {
136 if (obj === null || obj === undefined) return null;
137 if (typeof obj !== 'object' || Array.isArray(obj)) return null;
138 const v = obj[prop];
139 return v === undefined ? null : v;
140 }
141
142 /** @param {any} obj @param {any} idx */
143 function indexGet(obj, idx) {
144 if (obj === null || obj === undefined || idx === null) return null;
145 if (Array.isArray(obj)) {
146 if (typeof idx !== 'number' || !Number.isInteger(idx)) {
147 throw new SqlEvaluationError('Array index must be an integer');
148 }
149 const i = idx < 0 ? obj.length + idx : idx;
150 const v = obj[i];
151 return v === undefined ? null : v;
152 }
153 if (typeof obj === 'object') {
154 if (typeof idx !== 'string') throw new SqlEvaluationError('Object index must be a string');
155 const v = obj[idx];
156 return v === undefined ? null : v;
157 }
158 return null;
159 }
160
161 /**
162 * @param {import('./parser.js').LogicalNode} node
163 * @param {Record<string, any>} ctx
164 * @param {Record<string, (...args: any[]) => any>} functions
165 */
166 function evalLogical(node, ctx, functions) {
167 const left = evalNode(node.left, ctx, functions);
168 if (node.op === 'AND') {
169 if (left === false) return false;
170 const right = evalNode(node.right, ctx, functions);
171 if (right === false) return false;
172 if (left === null || right === null) return null;
173 return true;
174 }
175 if (left === true) return true;
176 const right = evalNode(node.right, ctx, functions);
177 if (right === true) return true;
178 if (left === null || right === null) return null;
179 return false;
180 }
181
182 /**
183 * @param {import('./parser.js').ComparisonNode} node
184 * @param {Record<string, any>} ctx
185 * @param {Record<string, (...args: any[]) => any>} functions
186 */
187 function evalComparison(node, ctx, functions) {
188 const l = evalNode(node.left, ctx, functions);
189 const r = evalNode(node.right, ctx, functions);
190 if (l === null || r === null) return null;
191 switch (node.op) {
192 case '=':
193 return deepEqual(l, r);
194 case '!=':
195 return !deepEqual(l, r);
196 case '<':
197 case '>':
198 case '<=':
199 case '>=':
200 requireOrderable(l, r, node.op);
201 if (node.op === '<') return l < r;
202 if (node.op === '>') return l > r;
203 if (node.op === '<=') return l <= r;
204 return l >= r;
205 default:
206 throw new SqlEvaluationError(`Unknown comparison operator: ${node.op}`);
207 }
208 }
209
210 /** @param {any} l @param {any} r @param {string} op */
211 function requireOrderable(l, r, op) {
212 const bothNumbers = typeof l === 'number' && typeof r === 'number';
213 const bothStrings = typeof l === 'string' && typeof r === 'string';
214 if (!bothNumbers && !bothStrings) {
215 throw new SqlEvaluationError(`Cannot compare ${typeName(l)} and ${typeName(r)} with ${op}`);
216 }
217 }
218
219 /**
220 * @param {import('./parser.js').LikeNode} node
221 * @param {Record<string, any>} ctx
222 * @param {Record<string, (...args: any[]) => any>} functions
223 */
224 function evalLike(node, ctx, functions) {
225 const v = evalNode(node.expr, ctx, functions);
226 const pattern = evalNode(node.pattern, ctx, functions);
227 if (v === null || pattern === null) return null;
228 if (typeof v !== 'string' || typeof pattern !== 'string') {
229 throw new SqlEvaluationError('LIKE requires string operands');
230 }
231 const matched = likeToRegExp(pattern).test(v);
232 return node.negate ? !matched : matched;
233 }
234
235 /** @param {string} pattern */
236 function likeToRegExp(pattern) {
237 let re = '';
238 for (const ch of pattern) {
239 if (ch === '%') re += '.*';
240 else if (ch === '_') re += '.';
241 else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
242 }
243 return new RegExp(`^${re}$`, 's');
244 }
245
246 /**
247 * @param {import('./parser.js').InListNode} node
248 * @param {Record<string, any>} ctx
249 * @param {Record<string, (...args: any[]) => any>} functions
250 */
251 function evalInList(node, ctx, functions) {
252 const v = evalNode(node.expr, ctx, functions);
253 if (v === null) return null;
254 let sawNull = false;
255 for (const itemNode of node.items) {
256 const item = evalNode(itemNode, ctx, functions);
257 if (item === null) {
258 sawNull = true;
259 continue;
260 }
261 if (deepEqual(v, item)) return !node.negate;
262 }
263 if (sawNull) return null;
264 return node.negate;
265 }
266
267 /**
268 * @param {import('./parser.js').InArrayNode} node
269 * @param {Record<string, any>} ctx
270 * @param {Record<string, (...args: any[]) => any>} functions
271 */
272 function evalInArray(node, ctx, functions) {
273 const v = evalNode(node.expr, ctx, functions);
274 const arr = evalNode(node.array, ctx, functions);
275 if (v === null || arr === null) return null;
276 if (!Array.isArray(arr)) throw new SqlEvaluationError('IN requires an array value');
277 let sawNull = false;
278 for (const item of arr) {
279 if (item === null) {
280 sawNull = true;
281 continue;
282 }
283 if (deepEqual(v, item)) return !node.negate;
284 }
285 if (sawNull) return null;
286 return node.negate;
287 }
288
289 /**
290 * @param {import('./parser.js').BinaryNode} node
291 * @param {Record<string, any>} ctx
292 * @param {Record<string, (...args: any[]) => any>} functions
293 */
294 function evalBinary(node, ctx, functions) {
295 const l = evalNode(node.left, ctx, functions);
296 const r = evalNode(node.right, ctx, functions);
297 if (node.op === '||') {
298 if (l === null || r === null) return null;
299 return stringify(l) + stringify(r);
300 }
301 if (l === null || r === null) return null;
302 if (typeof l !== 'number' || typeof r !== 'number') {
303 throw new SqlEvaluationError(`Operator ${node.op} requires numbers, got ${typeName(l)} and ${typeName(r)}`);
304 }
305 switch (node.op) {
306 case '+':
307 return l + r;
308 case '-':
309 return l - r;
310 case '*':
311 return l * r;
312 case '/':
313 if (r === 0) throw new SqlEvaluationError('Division by zero');
314 return l / r;
315 case '%':
316 if (r === 0) throw new SqlEvaluationError('Division by zero');
317 return l % r;
318 default:
319 throw new SqlEvaluationError(`Unknown operator: ${node.op}`);
320 }
321 }
322
323 /** @param {any} v */
324 function stringify(v) {
325 return typeof v === 'string' ? v : JSON.stringify(v);
326 }
327
328 /**
329 * @param {import('./parser.js').CallNode} node
330 * @param {Record<string, any>} ctx
331 * @param {Record<string, (...args: any[]) => any>} functions
332 */
333 function evalCall(node, ctx, functions) {
334 const fn = functions[node.name.toUpperCase()];
335 if (typeof fn !== 'function') {
336 throw new SqlEvaluationError(`Unknown function: ${node.name}`);
337 }
338 const args = node.args.map((a) => evalNode(a, ctx, functions));
339 return fn(...args);
340 }
341
342 /** @param {any} a @param {any} b */
343 function deepEqual(a, b) {
344 if (a === b) return true;
345 if (Array.isArray(a) && Array.isArray(b)) {
346 if (a.length !== b.length) return false;
347 return a.every((v, i) => deepEqual(v, b[i]));
348 }
349 if (isPlainObject(a) && isPlainObject(b)) {
350 const ak = Object.keys(a);
351 const bk = Object.keys(b);
352 if (ak.length !== bk.length) return false;
353 return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
354 }
355 return false;
356 }
357
358 /** @param {any} v */
359 function isPlainObject(v) {
360 return typeof v === 'object' && v !== null && !Array.isArray(v);
361 }
362
363 /** @param {any} v */
364 function typeName(v) {
365 if (v === null) return 'null';
366 if (Array.isArray(v)) return 'array';
367 return typeof v;
368 }
369
370 /** @param {Record<string, any>|undefined} obj */
371 function uppercaseKeys(obj) {
372 if (!obj) return {};
373 /** @type {Record<string, any>} */
374 const out = {};
375 for (const [k, v] of Object.entries(obj)) out[k.toUpperCase()] = v;
376 return out;
377 }