Add jsonql-js as a selectable query language on the Query page
- 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 | |
| Blog/Components/Pages/Query.razor.js | +21 -5 | |
| Blog/wwwroot/jsonql-js/errors.js | added | +25 -0 |
| Blog/wwwroot/jsonql-js/evaluate.js | added | +377 -0 |
| Blog/wwwroot/jsonql-js/functions.js | added | +27 -0 |
| Blog/wwwroot/jsonql-js/index.js | added | +19 -0 |
| Blog/wwwroot/jsonql-js/lexer.js | added | +197 -0 |
| Blog/wwwroot/jsonql-js/parser.js | added | +389 -0 |
| Blog/wwwroot/jsonql-js/query.js | added | +371 -0 |
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 | +} | |