Add jsonql-js as a selectable query language on the Query page

Vendors jsonql-js (a pure-JS, zero-dependency SQL-like query language for JSON, developed separately) into wwwroot/jsonql-js/ as static ESM modules, served the same way dactal.js already is. The Query page gains a "Query language" dropdown (jsonql-js default, DACTAL as the alternate); switching it swaps the textarea to that language's equivalent default query and re-runs it. jsonql-js queries run directly against DACTAL's already-loaded personen array rather than re-fetching brp.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-08-15 16:20 UTC
commit
fba8b609d8f4cf04d36c0d9c23d31ba06ef62f84
parent
10de9bcb21
tree
browse at this commit

9 files changed +1439 -6

Blog/Components/Pages/Query.razor +13 -1

@@ -3,7 +3,16 @@
3 3 <PageScript Src="./Components/Pages/Query.razor.js"/>
4 4
5 5 <main>
6 - <textarea id="input" style="height:20vh" autofocus>personen.gezag:type=EenhoofdigOuderlijkGezag</textarea>
6 + <div class="flex-row" style="align-items:center">
7 + <label for="lang">Query language</label>
8 + <select id="lang">
9 + <option value="jsonql" selected>jsonql-js</option>
10 + <option value="dactal">DACTAL</option>
11 + </select>
12 + </div>
13 + <textarea id="input" style="height:20vh" autofocus>SELECT *
14 +FROM personen AS p
15 +WHERE 'EenhoofdigOuderlijkGezag' IN p.gezag[].type</textarea>
7 16 <div id="results">
8 17 </div>
9 18 <Log/>
@@ -11,6 +20,9 @@
11 20 <fieldset class="docs">
12 21 <legend>Credits</legend>
13 22 <div>
23 + jsonql-js, a pure-JS SQL-like query language for JSON
24 + </div>
25 + <div>
14 26 <a href="https://dactal.org">DACTAL</a> by <a href="https://furia.com">glenn mcdonald</a>
15 27 </div>
16 28 <div>

Blog/Components/Pages/Query.razor.js +21 -5

@@ -1,5 +1,6 @@
1 1 import {getById, writeError, writeDebug, h, t} from '/common.module.js'
2 2 import {DACTAL} from "/dactal.js"
3 +import {query as jsonql} from "/jsonql-js/index.js"
3 4
4 5 const enterEvent = new KeyboardEvent('keydown', {
5 6 key: 'Enter',
@@ -10,34 +11,49 @@ const enterEvent = new KeyboardEvent('keydown', {
10 11 cancelable: true
11 12 });
12 13
14 +const defaultQueries = {
15 + jsonql: `SELECT *
16 +FROM personen AS p
17 +WHERE 'EenhoofdigOuderlijkGezag' IN p.gezag[].type`,
18 + dactal: "personen.gezag:type=EenhoofdigOuderlijkGezag",
19 +}
20 +
13 21 let dactal
14 22
15 23 export async function onLoad() {
16 24 const input = getById("input")
25 + const lang = getById("lang")
17 26 dactal = new DACTAL()
18 27
19 28 fetch("/brp.json").then(async res => {
20 29 const cdata = await res.json()
21 30 dactal.load(cdata, "personen")
22 31 writeDebug("Loaded brp data")
23 - await runQuery(input.value)
32 + await runQuery(input.value, lang.value)
24 33 })
25 -
34 +
26 35 input.onkeydown = async (e) => {
27 36 if (e.key === "Enter" && !e.shiftKey) {
28 37 e.preventDefault()
29 - await runQuery(input.value)
38 + await runQuery(input.value, lang.value)
30 39 }
31 40 }
32 41
42 + lang.onchange = async () => {
43 + input.value = defaultQueries[lang.value]
44 + await runQuery(input.value, lang.value)
45 + }
46 +
33 47 input.dispatchEvent(enterEvent)
34 48 }
35 49
36 -async function runQuery(query) {
50 +async function runQuery(query, lang) {
37 51 const results = getById("results")
38 52
39 53 try {
40 - let result = await dactal.query(query)
54 + let result = lang === "jsonql"
55 + ? jsonql(query, {personen: dactal.data.personen ?? []})
56 + : await dactal.query(query)
41 57
42 58 results.replaceChildren(renderValue(result.slice(0, 100)))
43 59

Blog/wwwroot/jsonql-js/errors.js +25 -0

@@ -0,0 +1,25 @@
1 +/** Thrown while tokenizing or parsing an expression. */
2 +export class SqlSyntaxError extends Error {
3 + /**
4 + * @param {string} message
5 + * @param {{pos?: number, line?: number, col?: number}} [location]
6 + */
7 + constructor(message, location = {}) {
8 + const { pos, line, col } = location;
9 + const suffix = line != null ? ` (line ${line}, col ${col})` : '';
10 + super(`${message}${suffix}`);
11 + this.name = 'SqlSyntaxError';
12 + this.pos = pos;
13 + this.line = line;
14 + this.col = col;
15 + }
16 +}
17 +
18 +/** Thrown while evaluating a parsed expression against data (type errors, unknown functions, etc). */
19 +export class SqlEvaluationError extends Error {
20 + /** @param {string} message */
21 + constructor(message) {
22 + super(message);
23 + this.name = 'SqlEvaluationError';
24 + }
25 +}

Blog/wwwroot/jsonql-js/evaluate.js +377 -0

@@ -0,0 +1,377 @@
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 +}

Blog/wwwroot/jsonql-js/functions.js +27 -0

@@ -0,0 +1,27 @@
1 +import { SqlEvaluationError } from './errors.js';
2 +
3 +/**
4 + * Builtin functions callable from expressions, keyed by uppercase name.
5 + * Extend/override at call time via `evaluate(ast, ctx, { functions })`.
6 + * @type {Record<string, (...args: any[]) => any>}
7 + */
8 +export const builtins = {
9 + LOWER: (v) => (v === null || v === undefined ? null : String(v).toLowerCase()),
10 + UPPER: (v) => (v === null || v === undefined ? null : String(v).toUpperCase()),
11 + LENGTH: (v) => {
12 + if (v === null || v === undefined) return null;
13 + if (typeof v === 'string' || Array.isArray(v)) return v.length;
14 + throw new SqlEvaluationError('LENGTH requires a string or array');
15 + },
16 + ABS: (v) => {
17 + if (v === null || v === undefined) return null;
18 + if (typeof v !== 'number') throw new SqlEvaluationError('ABS requires a number');
19 + return Math.abs(v);
20 + },
21 + COALESCE: (...args) => {
22 + for (const a of args) {
23 + if (a !== null && a !== undefined) return a;
24 + }
25 + return null;
26 + },
27 +};

Blog/wwwroot/jsonql-js/index.js +19 -0

@@ -0,0 +1,19 @@
1 +export { parseExpression } from './parser.js';
2 +export { evaluate } from './evaluate.js';
3 +export { parseQuery, executeQuery, query } from './query.js';
4 +export { SqlSyntaxError, SqlEvaluationError } from './errors.js';
5 +
6 +import { parseExpression } from './parser.js';
7 +import { evaluate } from './evaluate.js';
8 +
9 +/**
10 + * Parses (if needed) and evaluates a SQL-like expression against a JSON context object.
11 + * @param {string|import('./parser.js').Expr} source - expression text, or an already-parsed AST
12 + * @param {Record<string, any>} context
13 + * @param {{functions?: Record<string, (...args: any[]) => any>}} [options]
14 + * @returns {any}
15 + */
16 +export function evaluateExpression(source, context, options) {
17 + const ast = typeof source === 'string' ? parseExpression(source) : source;
18 + return evaluate(ast, context, options);
19 +}

Blog/wwwroot/jsonql-js/lexer.js +197 -0

@@ -0,0 +1,197 @@
1 +import { SqlSyntaxError } from './errors.js';
2 +
3 +/** @typedef {{type: string, value: any, pos: number, line: number, col: number}} Token */
4 +
5 +const KEYWORDS = new Set([
6 + 'AND', 'OR', 'NOT', 'IS', 'NULL', 'LIKE', 'IN', 'BETWEEN', 'TRUE', 'FALSE',
7 + 'SELECT', 'FROM', 'WHERE', 'AS',
8 + 'ORDER', 'BY', 'ASC', 'DESC', 'LIMIT',
9 + 'JOIN', 'INNER', 'LEFT', 'CROSS', 'ON', 'UNNEST',
10 +]);
11 +
12 +const TWO_CHAR_PUNCT = new Set(['!=', '<>', '<=', '>=', '||']);
13 +const ONE_CHAR_PUNCT = new Set(['(', ')', '[', ']', '{', '}', ':', ',', '.', '+', '-', '*', '/', '%', '=', '<', '>']);
14 +
15 +/**
16 + * Tokenizes an expression string into a flat array of tokens, terminated by an `eof` token.
17 + * @param {string} source
18 + * @returns {Token[]}
19 + */
20 +export function tokenize(source) {
21 + const tokens = [];
22 + const n = source.length;
23 + let i = 0;
24 + let line = 1;
25 + let col = 1;
26 +
27 + function advance(count = 1) {
28 + for (let k = 0; k < count; k++) {
29 + if (source[i] === '\n') {
30 + line++;
31 + col = 1;
32 + } else {
33 + col++;
34 + }
35 + i++;
36 + }
37 + }
38 +
39 + /** @param {string} message @param {number} atPos @param {number} atLine @param {number} atCol */
40 + function fail(message, atPos, atLine, atCol) {
41 + throw new SqlSyntaxError(message, { pos: atPos, line: atLine, col: atCol });
42 + }
43 +
44 + while (i < n) {
45 + const ch = source[i];
46 +
47 + if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
48 + advance();
49 + continue;
50 + }
51 +
52 + if (ch === '-' && source[i + 1] === '-') {
53 + while (i < n && source[i] !== '\n') advance();
54 + continue;
55 + }
56 +
57 + if (ch === '/' && source[i + 1] === '*') {
58 + const startPos = i, startLine = line, startCol = col;
59 + advance(2);
60 + let closed = false;
61 + while (i < n) {
62 + if (source[i] === '*' && source[i + 1] === '/') {
63 + advance(2);
64 + closed = true;
65 + break;
66 + }
67 + advance();
68 + }
69 + if (!closed) fail('Unterminated block comment', startPos, startLine, startCol);
70 + continue;
71 + }
72 +
73 + const startPos = i, startLine = line, startCol = col;
74 +
75 + if (isDigit(ch)) {
76 + let s = '';
77 + while (i < n && isDigit(source[i])) {
78 + s += source[i];
79 + advance();
80 + }
81 + if (source[i] === '.' && isDigit(source[i + 1])) {
82 + s += '.';
83 + advance();
84 + while (i < n && isDigit(source[i])) {
85 + s += source[i];
86 + advance();
87 + }
88 + }
89 + if (source[i] === 'e' || source[i] === 'E') {
90 + let j = i + 1;
91 + if (source[j] === '+' || source[j] === '-') j++;
92 + if (isDigit(source[j])) {
93 + let k = j;
94 + while (isDigit(source[k])) k++;
95 + s += source.slice(i, k);
96 + while (i < k) advance();
97 + }
98 + }
99 + tokens.push({ type: 'number', value: Number(s), pos: startPos, line: startLine, col: startCol });
100 + continue;
101 + }
102 +
103 + if (ch === "'") {
104 + advance();
105 + let s = '';
106 + let closed = false;
107 + while (i < n) {
108 + if (source[i] === "'") {
109 + if (source[i + 1] === "'") {
110 + s += "'";
111 + advance(2);
112 + continue;
113 + }
114 + advance();
115 + closed = true;
116 + break;
117 + }
118 + s += source[i];
119 + advance();
120 + }
121 + if (!closed) fail('Unterminated string literal', startPos, startLine, startCol);
122 + tokens.push({ type: 'string', value: s, pos: startPos, line: startLine, col: startCol });
123 + continue;
124 + }
125 +
126 + if (ch === '"') {
127 + advance();
128 + let s = '';
129 + let closed = false;
130 + while (i < n) {
131 + if (source[i] === '"') {
132 + if (source[i + 1] === '"') {
133 + s += '"';
134 + advance(2);
135 + continue;
136 + }
137 + advance();
138 + closed = true;
139 + break;
140 + }
141 + s += source[i];
142 + advance();
143 + }
144 + if (!closed) fail('Unterminated quoted identifier', startPos, startLine, startCol);
145 + tokens.push({ type: 'ident', value: s, pos: startPos, line: startLine, col: startCol });
146 + continue;
147 + }
148 +
149 + if (isIdentStart(ch)) {
150 + let s = '';
151 + while (i < n && isIdentPart(source[i])) {
152 + s += source[i];
153 + advance();
154 + }
155 + const upper = s.toUpperCase();
156 + if (KEYWORDS.has(upper)) {
157 + tokens.push({ type: upper, value: s, pos: startPos, line: startLine, col: startCol });
158 + } else {
159 + tokens.push({ type: 'ident', value: s, pos: startPos, line: startLine, col: startCol });
160 + }
161 + continue;
162 + }
163 +
164 + const two = source.slice(i, i + 2);
165 + if (TWO_CHAR_PUNCT.has(two)) {
166 + tokens.push({ type: two, value: two, pos: startPos, line: startLine, col: startCol });
167 + advance(2);
168 + continue;
169 + }
170 +
171 + if (ONE_CHAR_PUNCT.has(ch)) {
172 + tokens.push({ type: ch, value: ch, pos: startPos, line: startLine, col: startCol });
173 + advance();
174 + continue;
175 + }
176 +
177 + fail(`Unexpected character ${JSON.stringify(ch)}`, startPos, startLine, startCol);
178 + }
179 +
180 + tokens.push({ type: 'eof', value: null, pos: i, line, col });
181 + return tokens;
182 +}
183 +
184 +/** @param {string} ch */
185 +function isDigit(ch) {
186 + return ch >= '0' && ch <= '9';
187 +}
188 +
189 +/** @param {string} ch */
190 +function isIdentStart(ch) {
191 + return ch !== undefined && /[A-Za-z_]/.test(ch);
192 +}
193 +
194 +/** @param {string} ch */
195 +function isIdentPart(ch) {
196 + return ch !== undefined && /[A-Za-z0-9_]/.test(ch);
197 +}

Blog/wwwroot/jsonql-js/parser.js +389 -0

@@ -0,0 +1,389 @@
1 +import { tokenize } from './lexer.js';
2 +import { SqlSyntaxError } from './errors.js';
3 +
4 +/**
5 + * AST node shapes produced by the parser:
6 + *
7 + * @typedef {{type: 'Literal', value: string|number|boolean|null}} LiteralNode
8 + * @typedef {{type: 'ArrayLiteral', elements: Expr[]}} ArrayLiteralNode
9 + * @typedef {{type: 'ObjectLiteral', properties: {key: string, value: Expr}[]}} ObjectLiteralNode
10 + * @typedef {{type: 'Identifier', name: string}} IdentifierNode
11 + * @typedef {{type: 'Member', object: Expr, property: string}} MemberNode
12 + * @typedef {{type: 'Index', object: Expr, index: Expr}} IndexNode
13 + * @typedef {{type: 'Unnest', object: Expr}} UnnestNode
14 + * @typedef {{type: 'Call', name: string, args: Expr[]}} CallNode
15 + * @typedef {{type: 'Negate', expr: Expr}} NegateNode
16 + * @typedef {{type: 'Not', expr: Expr}} NotNode
17 + * @typedef {{type: 'Logical', op: 'AND'|'OR', left: Expr, right: Expr}} LogicalNode
18 + * @typedef {{type: 'Comparison', op: '='|'!='|'<'|'>'|'<='|'>=', left: Expr, right: Expr}} ComparisonNode
19 + * @typedef {{type: 'IsNull', expr: Expr, negate: boolean}} IsNullNode
20 + * @typedef {{type: 'Like', expr: Expr, pattern: Expr, negate: boolean}} LikeNode
21 + * @typedef {{type: 'InList', expr: Expr, items: Expr[], negate: boolean}} InListNode
22 + * @typedef {{type: 'InArray', expr: Expr, array: Expr, negate: boolean}} InArrayNode
23 + * @typedef {{type: 'Binary', op: '+'|'-'|'*'|'/'|'%'|'||', left: Expr, right: Expr}} BinaryNode
24 + * @typedef {LiteralNode|ArrayLiteralNode|ObjectLiteralNode|IdentifierNode|MemberNode|IndexNode|UnnestNode|CallNode|NegateNode|NotNode|LogicalNode|ComparisonNode|IsNullNode|LikeNode|InListNode|InArrayNode|BinaryNode} Expr
25 + */
26 +
27 +const COMPARISON_OPS = new Set(['=', '!=', '<>', '<', '>', '<=', '>=']);
28 +
29 +export class Parser {
30 + /** @param {import('./lexer.js').Token[]} tokens */
31 + constructor(tokens) {
32 + this.tokens = tokens;
33 + this.pos = 0;
34 + }
35 +
36 + peek() {
37 + return this.tokens[this.pos];
38 + }
39 +
40 + peekNext() {
41 + return this.tokens[this.pos + 1];
42 + }
43 +
44 + next() {
45 + return this.tokens[this.pos++];
46 + }
47 +
48 + /** @param {string} type */
49 + check(type) {
50 + return this.peek().type === type;
51 + }
52 +
53 + /** @param {string} type */
54 + expect(type) {
55 + const t = this.peek();
56 + if (t.type !== type) {
57 + throw new SqlSyntaxError(`Expected ${type} but found ${describeToken(t)}`, t);
58 + }
59 + return this.next();
60 + }
61 +
62 + /** @returns {Expr} */
63 + parseExpression() {
64 + const expr = this.parseOr();
65 + this.expect('eof');
66 + return expr;
67 + }
68 +
69 + /** @returns {Expr} */
70 + parseOr() {
71 + let node = this.parseAnd();
72 + while (this.check('OR')) {
73 + this.next();
74 + const right = this.parseAnd();
75 + node = { type: 'Logical', op: 'OR', left: node, right };
76 + }
77 + return node;
78 + }
79 +
80 + /** @returns {Expr} */
81 + parseAnd() {
82 + let node = this.parseNot();
83 + while (this.check('AND')) {
84 + this.next();
85 + const right = this.parseNot();
86 + node = { type: 'Logical', op: 'AND', left: node, right };
87 + }
88 + return node;
89 + }
90 +
91 + /** @returns {Expr} */
92 + parseNot() {
93 + if (this.check('NOT')) {
94 + this.next();
95 + const expr = this.parseNot();
96 + return { type: 'Not', expr };
97 + }
98 + return this.parseComparison();
99 + }
100 +
101 + /** @returns {Expr} */
102 + parseComparison() {
103 + const left = this.parseAdditive();
104 + const t = this.peek();
105 +
106 + if (COMPARISON_OPS.has(t.type)) {
107 + this.next();
108 + const right = this.parseAdditive();
109 + return { type: 'Comparison', op: t.type === '<>' ? '!=' : t.type, left, right };
110 + }
111 + if (t.type === 'IS') {
112 + this.next();
113 + let negate = false;
114 + if (this.check('NOT')) {
115 + negate = true;
116 + this.next();
117 + }
118 + this.expect('NULL');
119 + return { type: 'IsNull', expr: left, negate };
120 + }
121 + if (t.type === 'BETWEEN') {
122 + this.next();
123 + return this.finishBetween(left, false);
124 + }
125 + if (t.type === 'LIKE') {
126 + this.next();
127 + const pattern = this.parseAdditive();
128 + return { type: 'Like', expr: left, pattern, negate: false };
129 + }
130 + if (t.type === 'IN') {
131 + this.next();
132 + return this.parseInRhs(left, false);
133 + }
134 + if (t.type === 'NOT') {
135 + const t2 = this.peekNext();
136 + if (t2.type === 'LIKE') {
137 + this.next();
138 + this.next();
139 + const pattern = this.parseAdditive();
140 + return { type: 'Like', expr: left, pattern, negate: true };
141 + }
142 + if (t2.type === 'IN') {
143 + this.next();
144 + this.next();
145 + return this.parseInRhs(left, true);
146 + }
147 + if (t2.type === 'BETWEEN') {
148 + this.next();
149 + this.next();
150 + return this.finishBetween(left, true);
151 + }
152 + }
153 + return left;
154 + }
155 +
156 + /**
157 + * @param {Expr} left
158 + * @param {boolean} negate
159 + * @returns {Expr}
160 + */
161 + finishBetween(left, negate) {
162 + const lo = this.parseAdditive();
163 + this.expect('AND');
164 + const hi = this.parseAdditive();
165 + if (!negate) {
166 + return {
167 + type: 'Logical',
168 + op: 'AND',
169 + left: { type: 'Comparison', op: '>=', left, right: lo },
170 + right: { type: 'Comparison', op: '<=', left, right: hi },
171 + };
172 + }
173 + return {
174 + type: 'Logical',
175 + op: 'OR',
176 + left: { type: 'Comparison', op: '<', left, right: lo },
177 + right: { type: 'Comparison', op: '>', left, right: hi },
178 + };
179 + }
180 +
181 + /**
182 + * @param {Expr} left
183 + * @param {boolean} negate
184 + * @returns {Expr}
185 + */
186 + parseInRhs(left, negate) {
187 + if (this.check('(')) {
188 + this.next();
189 + const items = [this.parseOr()];
190 + while (this.check(',')) {
191 + this.next();
192 + items.push(this.parseOr());
193 + }
194 + this.expect(')');
195 + return { type: 'InList', expr: left, items, negate };
196 + }
197 + const array = this.parseAdditive();
198 + return { type: 'InArray', expr: left, array, negate };
199 + }
200 +
201 + /** @returns {Expr} */
202 + parseAdditive() {
203 + let node = this.parseMultiplicative();
204 + while (this.check('+') || this.check('-') || this.check('||')) {
205 + const op = /** @type {'+'|'-'|'||'} */ (this.next().type);
206 + const right = this.parseMultiplicative();
207 + node = { type: 'Binary', op, left: node, right };
208 + }
209 + return node;
210 + }
211 +
212 + /** @returns {Expr} */
213 + parseMultiplicative() {
214 + let node = this.parseUnary();
215 + while (this.check('*') || this.check('/') || this.check('%')) {
216 + const op = /** @type {'*'|'/'|'%'} */ (this.next().type);
217 + const right = this.parseUnary();
218 + node = { type: 'Binary', op, left: node, right };
219 + }
220 + return node;
221 + }
222 +
223 + /** @returns {Expr} */
224 + parseUnary() {
225 + if (this.check('-')) {
226 + this.next();
227 + const expr = this.parseUnary();
228 + return { type: 'Negate', expr };
229 + }
230 + return this.parsePostfix();
231 + }
232 +
233 + /** @returns {Expr} */
234 + parsePostfix() {
235 + let node = this.parsePrimary();
236 + while (true) {
237 + if (this.check('.')) {
238 + this.next();
239 + const idTok = this.expectIdentLike();
240 + node = { type: 'Member', object: node, property: idTok.value };
241 + } else if (this.check('[')) {
242 + this.next();
243 + if (this.check(']')) {
244 + this.next();
245 + node = { type: 'Unnest', object: node };
246 + } else {
247 + const index = this.parseOr();
248 + this.expect(']');
249 + node = { type: 'Index', object: node, index };
250 + }
251 + } else {
252 + break;
253 + }
254 + }
255 + return node;
256 + }
257 +
258 + expectIdentLike() {
259 + const t = this.peek();
260 + if (t.type !== 'ident') {
261 + throw new SqlSyntaxError(`Expected a property name but found ${describeToken(t)}`, t);
262 + }
263 + return this.next();
264 + }
265 +
266 + /** @returns {ObjectLiteralNode} */
267 + parseObjectLiteral() {
268 + this.expect('{');
269 + /** @type {{key: string, value: Expr}[]} */
270 + const properties = [];
271 + if (!this.check('}')) {
272 + properties.push(this.parseObjectProperty());
273 + while (this.check(',')) {
274 + this.next();
275 + properties.push(this.parseObjectProperty());
276 + }
277 + }
278 + this.expect('}');
279 + return { type: 'ObjectLiteral', properties };
280 + }
281 +
282 + /** @returns {{key: string, value: Expr}} */
283 + parseObjectProperty() {
284 + if (this.check('ident') && this.peekNext().type === ':') {
285 + const keyTok = this.next();
286 + this.next();
287 + const value = this.parseOr();
288 + return { key: keyTok.value, value };
289 + }
290 + const startTok = this.peek();
291 + const value = this.parseOr();
292 + return { key: inferPathKey(value, startTok), value };
293 + }
294 +
295 + /** @returns {Expr} */
296 + parsePrimary() {
297 + const t = this.peek();
298 + switch (t.type) {
299 + case 'number':
300 + this.next();
301 + return { type: 'Literal', value: t.value };
302 + case 'string':
303 + this.next();
304 + return { type: 'Literal', value: t.value };
305 + case 'TRUE':
306 + this.next();
307 + return { type: 'Literal', value: true };
308 + case 'FALSE':
309 + this.next();
310 + return { type: 'Literal', value: false };
311 + case 'NULL':
312 + this.next();
313 + return { type: 'Literal', value: null };
314 + case '(': {
315 + this.next();
316 + const expr = this.parseOr();
317 + this.expect(')');
318 + return expr;
319 + }
320 + case '[': {
321 + this.next();
322 + /** @type {Expr[]} */
323 + const elements = [];
324 + if (!this.check(']')) {
325 + elements.push(this.parseOr());
326 + while (this.check(',')) {
327 + this.next();
328 + elements.push(this.parseOr());
329 + }
330 + }
331 + this.expect(']');
332 + return { type: 'ArrayLiteral', elements };
333 + }
334 + case '{':
335 + return this.parseObjectLiteral();
336 + case 'ident': {
337 + this.next();
338 + if (this.check('(')) {
339 + this.next();
340 + /** @type {Expr[]} */
341 + const args = [];
342 + if (!this.check(')')) {
343 + args.push(this.parseOr());
344 + while (this.check(',')) {
345 + this.next();
346 + args.push(this.parseOr());
347 + }
348 + }
349 + this.expect(')');
350 + return { type: 'Call', name: t.value, args };
351 + }
352 + return { type: 'Identifier', name: t.value };
353 + }
354 + default:
355 + throw new SqlSyntaxError(`Unexpected token ${describeToken(t)}`, t);
356 + }
357 + }
358 +}
359 +
360 +/** @param {import('./lexer.js').Token} t */
361 +function describeToken(t) {
362 + if (t.type === 'eof') return 'end of input';
363 + return JSON.stringify(t.value ?? t.type);
364 +}
365 +
366 +/**
367 + * Infers a property/column key from a bare (unaliased) expression: an `Identifier`
368 + * uses its own name, a `Member` chain uses its trailing `.property`. Anything else
369 + * has no natural name and must be given one explicitly (`key: expr` / `expr AS key`).
370 + * @param {Expr} expr
371 + * @param {import('./lexer.js').Token} atToken - used for error position
372 + * @returns {string}
373 + */
374 +export function inferPathKey(expr, atToken) {
375 + if (expr.type === 'Identifier') return expr.name;
376 + if (expr.type === 'Member') return expr.property;
377 + throw new SqlSyntaxError('Cannot infer a name for this expression; give it one explicitly (`key: expr` or `expr AS key`)', atToken);
378 +}
379 +
380 +/**
381 + * Parses a SQL-like expression string into an AST.
382 + * @param {string} source
383 + * @returns {Expr}
384 + */
385 +export function parseExpression(source) {
386 + const tokens = tokenize(source);
387 + const parser = new Parser(tokens);
388 + return parser.parseExpression();
389 +}

Blog/wwwroot/jsonql-js/query.js +371 -0

@@ -0,0 +1,371 @@
1 +import { tokenize } from './lexer.js';
2 +import { Parser, inferPathKey } from './parser.js';
3 +import { SqlSyntaxError, SqlEvaluationError } from './errors.js';
4 +import { evaluate } from './evaluate.js';
5 +
6 +/**
7 + * @typedef {{kind: 'Star'}} StarSelect
8 + * @typedef {{kind: 'Shape', expr: import('./parser.js').ObjectLiteralNode}} ShapeSelect
9 + * @typedef {{kind: 'Columns', items: {expr: import('./parser.js').Expr, key: string}[]}} ColumnsSelect
10 + * @typedef {StarSelect|ShapeSelect|ColumnsSelect} Select
11 + * @typedef {{kind: 'Source', expr: import('./parser.js').Expr, alias: string|null}} SourceFromItem
12 + * @typedef {{kind: 'Unnest', expr: import('./parser.js').Expr, alias: string|null}} UnnestFromItem
13 + * @typedef {SourceFromItem|UnnestFromItem} FromItem
14 + * @typedef {{type: 'INNER'|'LEFT'|'CROSS', item: FromItem, on: import('./parser.js').Expr|null}} JoinClause
15 + * @typedef {{first: FromItem, joins: JoinClause[]}} From
16 + * @typedef {{expr: import('./parser.js').Expr, dir: 'ASC'|'DESC'}} OrderItem
17 + * @typedef {{type: 'Query', select: Select, from: From, where: import('./parser.js').Expr|null, orderBy: OrderItem[]|null, limit: import('./parser.js').Expr|null}} Query
18 + */
19 +
20 +class QueryParser extends Parser {
21 + /** @returns {Query} */
22 + parseQuery() {
23 + this.expect('SELECT');
24 + const select = this.parseSelect();
25 + this.expect('FROM');
26 + const from = this.parseFrom();
27 + let where = null;
28 + if (this.check('WHERE')) {
29 + this.next();
30 + where = this.parseOr();
31 + }
32 + let orderBy = null;
33 + if (this.check('ORDER')) {
34 + this.next();
35 + this.expect('BY');
36 + orderBy = this.parseOrderByList();
37 + }
38 + let limit = null;
39 + if (this.check('LIMIT')) {
40 + this.next();
41 + limit = this.parseOr();
42 + }
43 + this.expect('eof');
44 + return { type: 'Query', select, from, where, orderBy, limit };
45 + }
46 +
47 + /** @returns {OrderItem[]} */
48 + parseOrderByList() {
49 + const items = [this.parseOrderByItem()];
50 + while (this.check(',')) {
51 + this.next();
52 + items.push(this.parseOrderByItem());
53 + }
54 + return items;
55 + }
56 +
57 + /** @returns {OrderItem} */
58 + parseOrderByItem() {
59 + const expr = this.parseOr();
60 + let dir = 'ASC';
61 + if (this.check('ASC')) {
62 + this.next();
63 + } else if (this.check('DESC')) {
64 + this.next();
65 + dir = 'DESC';
66 + }
67 + return { expr, dir };
68 + }
69 +
70 + /** @returns {Select} */
71 + parseSelect() {
72 + if (this.check('*')) {
73 + this.next();
74 + return { kind: 'Star' };
75 + }
76 + if (this.check('{')) {
77 + const expr = this.parseObjectLiteral();
78 + return { kind: 'Shape', expr };
79 + }
80 + const items = [this.parseSelectItem()];
81 + while (this.check(',')) {
82 + this.next();
83 + items.push(this.parseSelectItem());
84 + }
85 + return { kind: 'Columns', items };
86 + }
87 +
88 + /** @returns {{expr: import('./parser.js').Expr, key: string}} */
89 + parseSelectItem() {
90 + const startTok = this.peek();
91 + const expr = this.parseOr();
92 + if (this.check('AS')) {
93 + this.next();
94 + const key = this.expectIdentLike().value;
95 + return { expr, key };
96 + }
97 + return { expr, key: inferPathKey(expr, startTok) };
98 + }
99 +
100 + /** @returns {From} */
101 + parseFrom() {
102 + const first = this.parseFromItem();
103 + /** @type {JoinClause[]} */
104 + const joins = [];
105 + while (this.isJoinStart()) {
106 + joins.push(this.parseJoinClause());
107 + }
108 + validateFromAliases(first, joins);
109 + return { first, joins };
110 + }
111 +
112 + /** @returns {boolean} */
113 + isJoinStart() {
114 + return this.check('JOIN') || this.check('INNER') || this.check('LEFT') || this.check('CROSS');
115 + }
116 +
117 + /** @returns {FromItem} */
118 + parseFromItem() {
119 + if (this.check('UNNEST')) {
120 + this.next();
121 + this.expect('(');
122 + const expr = this.parseOr();
123 + this.expect(')');
124 + let alias = null;
125 + if (this.check('AS')) {
126 + this.next();
127 + alias = this.expectIdentLike().value;
128 + }
129 + return { kind: 'Unnest', expr, alias };
130 + }
131 + const expr = this.parsePostfix();
132 + let alias = null;
133 + if (this.check('AS')) {
134 + this.next();
135 + alias = this.expectIdentLike().value;
136 + }
137 + return { kind: 'Source', expr, alias };
138 + }
139 +
140 + /** @returns {JoinClause} */
141 + parseJoinClause() {
142 + const startTok = this.peek();
143 + /** @type {'INNER'|'LEFT'|'CROSS'} */
144 + let type = 'INNER';
145 + if (this.check('INNER')) {
146 + this.next();
147 + this.expect('JOIN');
148 + } else if (this.check('LEFT')) {
149 + this.next();
150 + this.expect('JOIN');
151 + type = 'LEFT';
152 + } else if (this.check('CROSS')) {
153 + this.next();
154 + this.expect('JOIN');
155 + type = 'CROSS';
156 + } else {
157 + this.expect('JOIN');
158 + }
159 +
160 + const item = this.parseFromItem();
161 +
162 + let on = null;
163 + if (this.check('ON')) {
164 + this.next();
165 + on = this.parseOr();
166 + }
167 +
168 + if (type === 'CROSS' && on) {
169 + throw new SqlSyntaxError('CROSS JOIN cannot have an ON condition', startTok);
170 + }
171 + if (type !== 'CROSS' && item.kind !== 'Unnest' && !on) {
172 + throw new SqlSyntaxError('JOIN requires an ON condition (except CROSS JOIN or JOIN UNNEST(...))', startTok);
173 + }
174 +
175 + return { type, item, on };
176 + }
177 +}
178 +
179 +/**
180 + * @param {FromItem} first
181 + * @param {JoinClause[]} joins
182 + */
183 +function validateFromAliases(first, joins) {
184 + const hasJoins = joins.length > 0;
185 + if (first.kind === 'Unnest' && !first.alias) {
186 + throw new SqlSyntaxError('UNNEST(...) in FROM requires AS alias');
187 + }
188 + if (hasJoins && !first.alias) {
189 + throw new SqlSyntaxError('FROM sources must be aliased when using JOIN');
190 + }
191 + for (const join of joins) {
192 + if (!join.item.alias) {
193 + throw new SqlSyntaxError('Joined sources must be aliased');
194 + }
195 + }
196 +}
197 +
198 +/**
199 + * Parses a `SELECT ... FROM ... [WHERE ...] [ORDER BY ...] [LIMIT ...]` query string into an AST.
200 + * @param {string} source
201 + * @returns {Query}
202 + */
203 +export function parseQuery(source) {
204 + const tokens = tokenize(source);
205 + const parser = new QueryParser(tokens);
206 + return parser.parseQuery();
207 +}
208 +
209 +/**
210 + * Executes a parsed query against a JSON context object.
211 + * @param {Query} ast
212 + * @param {Record<string, any>} context
213 + * @param {{functions?: Record<string, (...args: any[]) => any>}} [options]
214 + * @returns {any[]}
215 + */
216 +export function executeQuery(ast, context, options = {}) {
217 + const hasJoins = ast.from.joins.length > 0;
218 + const firstArray = resolveFromItemArray(ast.from.first, context, options);
219 +
220 + /** @type {{ctx: any, pristineRow: any}[]} */
221 + let rows = firstArray.map((row) => ({
222 + ctx: ast.from.first.alias ? bind({}, row, ast.from.first.alias) : row,
223 + pristineRow: row,
224 + }));
225 +
226 + for (const join of ast.from.joins) {
227 + /** @type {{ctx: any, pristineRow: any}[]} */
228 + const nextRows = [];
229 + for (const left of rows) {
230 + const scopeCtx = { ...context, ...left.ctx };
231 + const rightArray = resolveFromItemArray(join.item, scopeCtx, options);
232 + let matchedAny = false;
233 + for (const rightRow of rightArray) {
234 + const ctx = bind(left.ctx, rightRow, /** @type {string} */ (join.item.alias));
235 + if (join.on && evaluate(join.on, ctx, options) !== true) continue;
236 + nextRows.push({ ctx, pristineRow: undefined });
237 + matchedAny = true;
238 + }
239 + if (!matchedAny && join.type === 'LEFT') {
240 + nextRows.push({ ctx: bind(left.ctx, null, /** @type {string} */ (join.item.alias)), pristineRow: undefined });
241 + }
242 + }
243 + rows = nextRows;
244 + }
245 +
246 + /** @type {{ctx: any, pristineRow: any}[]} */
247 + let matched = [];
248 + for (const r of rows) {
249 + if (ast.where && evaluate(ast.where, r.ctx, options) !== true) continue;
250 + matched.push(r);
251 + }
252 +
253 + if (ast.orderBy) {
254 + const comparator = makeRowComparator(ast.orderBy, options);
255 + matched = matched.slice().sort(comparator);
256 + }
257 +
258 + if (ast.limit) {
259 + const n = evaluate(ast.limit, context, options);
260 + if (n !== null) {
261 + if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
262 + throw new SqlEvaluationError('LIMIT must be a non-negative integer or NULL');
263 + }
264 + matched = matched.slice(0, n);
265 + }
266 + }
267 +
268 + return matched.map((r) =>
269 + ast.select.kind === 'Star' && !hasJoins ? r.pristineRow : project(ast.select, r.ctx, options),
270 + );
271 +}
272 +
273 +/**
274 + * @param {FromItem} item
275 + * @param {Record<string, any>} scopeCtx
276 + * @param {{functions?: Record<string, (...args: any[]) => any>}} options
277 + */
278 +function resolveFromItemArray(item, scopeCtx, options) {
279 + const value = evaluate(item.expr, scopeCtx, options);
280 + if (item.kind === 'Unnest') {
281 + if (value === null || value === undefined) return [];
282 + if (!Array.isArray(value)) throw new SqlEvaluationError('UNNEST requires an array value');
283 + return value;
284 + }
285 + if (!Array.isArray(value)) throw new SqlEvaluationError('FROM/JOIN source must reference an array');
286 + return value;
287 +}
288 +
289 +/**
290 + * Binds a row into a context: its own fields (if it's a plain object) plus its alias.
291 + * @param {Record<string, any>} baseCtx
292 + * @param {any} row
293 + * @param {string} alias
294 + */
295 +function bind(baseCtx, row, alias) {
296 + const flat = isPlainObject(row) ? row : {};
297 + return { ...baseCtx, ...flat, [alias]: row };
298 +}
299 +
300 +/** @param {any} v */
301 +function isPlainObject(v) {
302 + return typeof v === 'object' && v !== null && !Array.isArray(v);
303 +}
304 +
305 +/**
306 + * @param {OrderItem[]} orderBy
307 + * @param {{functions?: Record<string, (...args: any[]) => any>}} options
308 + */
309 +function makeRowComparator(orderBy, options) {
310 + return (a, b) => {
311 + for (const item of orderBy) {
312 + const va = evaluate(item.expr, a.ctx, options);
313 + const vb = evaluate(item.expr, b.ctx, options);
314 + // Nulls always sort last, regardless of ASC/DESC - checked before the
315 + // direction flip below, so DESC never pulls nulls back to the front.
316 + if (va === null && vb === null) continue;
317 + if (va === null) return 1;
318 + if (vb === null) return -1;
319 + const cmp = compareForOrder(va, vb);
320 + if (cmp !== 0) return item.dir === 'DESC' ? -cmp : cmp;
321 + }
322 + return 0;
323 + };
324 +}
325 +
326 +/** @param {any} a @param {any} b */
327 +function compareForOrder(a, b) {
328 + const ta = typeof a;
329 + const tb = typeof b;
330 + if (ta !== tb || (ta !== 'number' && ta !== 'string' && ta !== 'boolean')) {
331 + throw new SqlEvaluationError(`Cannot compare ${orderTypeName(a)} and ${orderTypeName(b)} in ORDER BY`);
332 + }
333 + if (a < b) return -1;
334 + if (a > b) return 1;
335 + return 0;
336 +}
337 +
338 +/** @param {any} v */
339 +function orderTypeName(v) {
340 + if (v === null) return 'null';
341 + if (Array.isArray(v)) return 'array';
342 + return typeof v;
343 +}
344 +
345 +/**
346 + * @param {Select} select
347 + * @param {Record<string, any>} ctx
348 + * @param {{functions?: Record<string, (...args: any[]) => any>}} options
349 + */
350 +function project(select, ctx, options) {
351 + if (select.kind === 'Star') return ctx;
352 + if (select.kind === 'Shape') return evaluate(select.expr, ctx, options);
353 + /** @type {Record<string, any>} */
354 + const out = {};
355 + for (const item of select.items) {
356 + out[item.key] = evaluate(item.expr, ctx, options);
357 + }
358 + return out;
359 +}
360 +
361 +/**
362 + * Parses (if needed) and executes a `SELECT ... FROM ... [WHERE ...] [ORDER BY ...] [LIMIT ...]` query.
363 + * @param {string|Query} source - query text, or an already-parsed AST
364 + * @param {Record<string, any>} context
365 + * @param {{functions?: Record<string, (...args: any[]) => any>}} [options]
366 + * @returns {any[]}
367 + */
368 +export function query(source, context, options) {
369 + const ast = typeof source === 'string' ? parseQuery(source) : source;
370 + return executeQuery(ast, context, options);
371 +}