Add GROUP BY support to the Query page, and render null and boolean results
- author
- Marijn Besseling <njirambem@gmail.com> · 2026-08-18 18:05 UTC
- commit
- e39ae7ad1beb42f9c52dc12097d8dcd33e0a4c08
- parent
- 30b0e5a329
- tree
- browse at this commit
7 files changed +372 -15
Blog/Components/Pages/Query.razor.js +7 -2
| @@ -262,10 +262,15 @@ const PAGE_SIZE = 10 | ||
| 262 | 262 | function renderValue(obj, depth = 0) { |
| 263 | 263 | if (Array.isArray(obj)) { |
| 264 | 264 | return renderList(obj, depth) |
| 265 | - } else if (obj !== null && typeof obj === "object") { | |
| 265 | + } else if (obj === null) { | |
| 266 | + // Every branch has to return a node: aggregates (MIN/SUM over an | |
| 267 | + // all-null group) and LEFT JOIN fills both put nulls in the results, | |
| 268 | + // and returning undefined here makes appendChild throw. | |
| 269 | + return h("span", {class: "json-null"}, "null") | |
| 270 | + } else if (typeof obj === "object") { | |
| 266 | 271 | return renderContainer("ul", Object.entries(obj), ([name, item]) => renderEntry(name, item, depth)) |
| 267 | 272 | } else if (typeof obj === "boolean") { |
| 268 | - return h("span", {class: obj ? "json-true" : "json-false"}) | |
| 273 | + return h("span", {class: obj ? "json-true" : "json-false"}, obj.toString()) | |
| 269 | 274 | } else if (typeof obj === "number") { |
| 270 | 275 | return h("span", {class: "json-number"}, obj.toString()) |
| 271 | 276 | } else if (typeof obj === "string") { |
Blog/wwwroot/app.css +4 -0
| @@ -498,6 +498,10 @@ span.name { | ||
| 498 | 498 | color: var(); |
| 499 | 499 | } |
| 500 | 500 | |
| 501 | +.json-null { | |
| 502 | + opacity: 50%; | |
| 503 | +} | |
| 504 | + | |
| 501 | 505 | /* -- Dialogs -------------------------------------------------------------- */ |
| 502 | 506 | |
| 503 | 507 | dialog { |
Blog/wwwroot/jsonql-js/aggregates.js +81 -0
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { SqlEvaluationError } from './errors.js'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Aggregate functions, keyed by uppercase name. Each one receives the argument | |
| 5 | + * value from every row of a group, in row order, and folds them into a single | |
| 6 | + * value. Following SQL, nulls are skipped rather than propagated, and an | |
| 7 | + * all-null (or empty) group aggregates to `null` - except COUNT, which counts. | |
| 8 | + * | |
| 9 | + * `COUNT(*)` passes a non-null placeholder for each row (see the `Star` argument | |
| 10 | + * handling in query.js), so it counts rows where `COUNT(expr)` counts non-nulls. | |
| 11 | + * | |
| 12 | + * These names are reserved by the query layer: unlike the builtins in | |
| 13 | + * functions.js they cannot be overridden via `{ functions }`, since shadowing | |
| 14 | + * one would silently turn a grouped query into a non-grouped one. | |
| 15 | + * @type {Record<string, (values: any[]) => any>} | |
| 16 | + */ | |
| 17 | +export const aggregates = { | |
| 18 | + COUNT: (values) => values.reduce((n, v) => (v === null ? n : n + 1), 0), | |
| 19 | + SUM: (values) => { | |
| 20 | + const nums = numbersOf('SUM', values); | |
| 21 | + return nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0); | |
| 22 | + }, | |
| 23 | + AVG: (values) => { | |
| 24 | + const nums = numbersOf('AVG', values); | |
| 25 | + return nums.length === 0 ? null : nums.reduce((a, b) => a + b, 0) / nums.length; | |
| 26 | + }, | |
| 27 | + MIN: (values) => extreme('MIN', values, -1), | |
| 28 | + MAX: (values) => extreme('MAX', values, 1), | |
| 29 | + ARRAY_AGG: (values) => values, | |
| 30 | +}; | |
| 31 | + | |
| 32 | +/** @param {string} name */ | |
| 33 | +export function isAggregateName(name) { | |
| 34 | + return Object.prototype.hasOwnProperty.call(aggregates, name.toUpperCase()); | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** @param {string} name @param {any[]} values */ | |
| 38 | +function numbersOf(name, values) { | |
| 39 | + /** @type {number[]} */ | |
| 40 | + const out = []; | |
| 41 | + for (const v of values) { | |
| 42 | + if (v === null) continue; | |
| 43 | + if (typeof v !== 'number') { | |
| 44 | + throw new SqlEvaluationError(`${name} requires numbers, got ${typeName(v)}`); | |
| 45 | + } | |
| 46 | + out.push(v); | |
| 47 | + } | |
| 48 | + return out; | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * @param {string} name | |
| 53 | + * @param {any[]} values | |
| 54 | + * @param {-1|1} sign - -1 keeps the smallest value, 1 the largest | |
| 55 | + */ | |
| 56 | +function extreme(name, values, sign) { | |
| 57 | + /** @type {number|string|null} */ | |
| 58 | + let best = null; | |
| 59 | + for (const v of values) { | |
| 60 | + if (v === null) continue; | |
| 61 | + if (typeof v !== 'number' && typeof v !== 'string') { | |
| 62 | + throw new SqlEvaluationError(`${name} requires numbers or strings, got ${typeName(v)}`); | |
| 63 | + } | |
| 64 | + if (best === null) { | |
| 65 | + best = v; | |
| 66 | + continue; | |
| 67 | + } | |
| 68 | + if (typeof v !== typeof best) { | |
| 69 | + throw new SqlEvaluationError(`${name} cannot mix numbers and strings`); | |
| 70 | + } | |
| 71 | + if (sign < 0 ? v < best : v > best) best = v; | |
| 72 | + } | |
| 73 | + return best; | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** @param {any} v */ | |
| 77 | +function typeName(v) { | |
| 78 | + if (v === null) return 'null'; | |
| 79 | + if (Array.isArray(v)) return 'array'; | |
| 80 | + return typeof v; | |
| 81 | +} | |
Blog/wwwroot/jsonql-js/evaluate.js +10 -0
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { SqlEvaluationError } from './errors.js'; |
| 2 | 2 | import { builtins } from './functions.js'; |
| 3 | +import { isAggregateName } from './aggregates.js'; | |
| 3 | 4 | |
| 4 | 5 | /** |
| 5 | 6 | * Evaluates a parsed expression AST against a JSON context object. |
| @@ -74,6 +75,8 @@ function evalNode(node, ctx, functions) { | ||
| 74 | 75 | return evalInArray(node, ctx, functions); |
| 75 | 76 | case 'Binary': |
| 76 | 77 | return evalBinary(node, ctx, functions); |
| 78 | + case 'Star': | |
| 79 | + throw new SqlEvaluationError('* is not a value; it is only valid as COUNT(*)'); | |
| 77 | 80 | default: |
| 78 | 81 | throw new SqlEvaluationError(`Unknown AST node type: ${/** @type {any} */ (node).type}`); |
| 79 | 82 | } |
| @@ -333,6 +336,13 @@ function stringify(v) { | ||
| 333 | 336 | function evalCall(node, ctx, functions) { |
| 334 | 337 | const fn = functions[node.name.toUpperCase()]; |
| 335 | 338 | if (typeof fn !== 'function') { |
| 339 | + // Aggregates are folded away by the query layer before evaluation, so one | |
| 340 | + // reaching here means it was used outside SELECT/HAVING/ORDER BY. | |
| 341 | + if (isAggregateName(node.name)) { | |
| 342 | + throw new SqlEvaluationError( | |
| 343 | + `${node.name.toUpperCase()} is an aggregate function; it can only be used in a query's SELECT, HAVING or ORDER BY clause`, | |
| 344 | + ); | |
| 345 | + } | |
| 336 | 346 | throw new SqlEvaluationError(`Unknown function: ${node.name}`); |
| 337 | 347 | } |
| 338 | 348 | const args = node.args.map((a) => evalNode(a, ctx, functions)); |
Blog/wwwroot/jsonql-js/lexer.js +1 -1
| @@ -5,7 +5,7 @@ import { SqlSyntaxError } from './errors.js'; | ||
| 5 | 5 | const KEYWORDS = new Set([ |
| 6 | 6 | 'AND', 'OR', 'NOT', 'IS', 'NULL', 'LIKE', 'IN', 'BETWEEN', 'TRUE', 'FALSE', |
| 7 | 7 | 'SELECT', 'FROM', 'WHERE', 'AS', |
| 8 | - 'ORDER', 'BY', 'ASC', 'DESC', 'LIMIT', | |
| 8 | + 'ORDER', 'BY', 'ASC', 'DESC', 'LIMIT', 'GROUP', 'HAVING', | |
| 9 | 9 | 'JOIN', 'INNER', 'LEFT', 'CROSS', 'ON', 'UNNEST', |
| 10 | 10 | ]); |
| 11 | 11 | |
Blog/wwwroot/jsonql-js/parser.js +54 -4
| @@ -11,7 +11,8 @@ import { SqlSyntaxError } from './errors.js'; | ||
| 11 | 11 | * @typedef {{type: 'Member', object: Expr, property: string}} MemberNode |
| 12 | 12 | * @typedef {{type: 'Index', object: Expr, index: Expr}} IndexNode |
| 13 | 13 | * @typedef {{type: 'Unnest', object: Expr}} UnnestNode |
| 14 | - * @typedef {{type: 'Call', name: string, args: Expr[]}} CallNode | |
| 14 | + * @typedef {{type: 'Star'}} StarNode - the `*` of `COUNT(*)`; only ever a Call argument | |
| 15 | + * @typedef {{type: 'Call', name: string, args: (Expr|StarNode)[]}} CallNode | |
| 15 | 16 | * @typedef {{type: 'Negate', expr: Expr}} NegateNode |
| 16 | 17 | * @typedef {{type: 'Not', expr: Expr}} NotNode |
| 17 | 18 | * @typedef {{type: 'Logical', op: 'AND'|'OR', left: Expr, right: Expr}} LogicalNode |
| @@ -21,7 +22,7 @@ import { SqlSyntaxError } from './errors.js'; | ||
| 21 | 22 | * @typedef {{type: 'InList', expr: Expr, items: Expr[], negate: boolean}} InListNode |
| 22 | 23 | * @typedef {{type: 'InArray', expr: Expr, array: Expr, negate: boolean}} InArrayNode |
| 23 | 24 | * @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 | + * @typedef {LiteralNode|ArrayLiteralNode|ObjectLiteralNode|IdentifierNode|MemberNode|IndexNode|UnnestNode|CallNode|StarNode|NegateNode|NotNode|LogicalNode|ComparisonNode|IsNullNode|LikeNode|InListNode|InArrayNode|BinaryNode} Expr | |
| 25 | 26 | */ |
| 26 | 27 | |
| 27 | 28 | const COMPARISON_OPS = new Set(['=', '!=', '<>', '<', '>', '<=', '>=']); |
| @@ -337,9 +338,14 @@ export class Parser { | ||
| 337 | 338 | this.next(); |
| 338 | 339 | if (this.check('(')) { |
| 339 | 340 | this.next(); |
| 340 | - /** @type {Expr[]} */ | |
| 341 | + /** @type {(Expr|StarNode)[]} */ | |
| 341 | 342 | const args = []; |
| 342 | - if (!this.check(')')) { | |
| 343 | + // `*` is a value nowhere else in the grammar, so it is only accepted | |
| 344 | + // as the whole argument list of COUNT. | |
| 345 | + if (this.check('*') && t.value.toUpperCase() === 'COUNT') { | |
| 346 | + this.next(); | |
| 347 | + args.push({ type: 'Star' }); | |
| 348 | + } else if (!this.check(')')) { | |
| 343 | 349 | args.push(this.parseOr()); |
| 344 | 350 | while (this.check(',')) { |
| 345 | 351 | this.next(); |
| @@ -364,6 +370,50 @@ function describeToken(t) { | ||
| 364 | 370 | } |
| 365 | 371 | |
| 366 | 372 | /** |
| 373 | + * Rebuilds `node` with each of its direct child expressions replaced by `fn(child)`. | |
| 374 | + * Leaf nodes are returned as-is; anything else is shallow-copied, so a rewrite never | |
| 375 | + * mutates the AST it walks (a parsed query can be executed more than once). | |
| 376 | + * @param {Expr} node | |
| 377 | + * @param {(child: Expr) => Expr} fn | |
| 378 | + * @returns {Expr} | |
| 379 | + */ | |
| 380 | +export function mapChildren(node, fn) { | |
| 381 | + switch (node.type) { | |
| 382 | + case 'Literal': | |
| 383 | + case 'Identifier': | |
| 384 | + case 'Star': | |
| 385 | + return node; | |
| 386 | + case 'ArrayLiteral': | |
| 387 | + return { ...node, elements: node.elements.map(fn) }; | |
| 388 | + case 'ObjectLiteral': | |
| 389 | + return { ...node, properties: node.properties.map((p) => ({ ...p, value: fn(p.value) })) }; | |
| 390 | + case 'Member': | |
| 391 | + case 'Unnest': | |
| 392 | + return { ...node, object: fn(node.object) }; | |
| 393 | + case 'Index': | |
| 394 | + return { ...node, object: fn(node.object), index: fn(node.index) }; | |
| 395 | + case 'Call': | |
| 396 | + return { ...node, args: node.args.map(fn) }; | |
| 397 | + case 'Negate': | |
| 398 | + case 'Not': | |
| 399 | + case 'IsNull': | |
| 400 | + return { ...node, expr: fn(node.expr) }; | |
| 401 | + case 'Logical': | |
| 402 | + case 'Comparison': | |
| 403 | + case 'Binary': | |
| 404 | + return { ...node, left: fn(node.left), right: fn(node.right) }; | |
| 405 | + case 'Like': | |
| 406 | + return { ...node, expr: fn(node.expr), pattern: fn(node.pattern) }; | |
| 407 | + case 'InList': | |
| 408 | + return { ...node, expr: fn(node.expr), items: node.items.map(fn) }; | |
| 409 | + case 'InArray': | |
| 410 | + return { ...node, expr: fn(node.expr), array: fn(node.array) }; | |
| 411 | + default: | |
| 412 | + return node; | |
| 413 | + } | |
| 414 | +} | |
| 415 | + | |
| 416 | +/** | |
| 367 | 417 | * Infers a property/column key from a bare (unaliased) expression: an `Identifier` |
| 368 | 418 | * uses its own name, a `Member` chain uses its trailing `.property`. Anything else |
| 369 | 419 | * has no natural name and must be given one explicitly (`key: expr` / `expr AS key`). |
Blog/wwwroot/jsonql-js/query.js +215 -8
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { tokenize } from './lexer.js'; |
| 2 | -import { Parser, inferPathKey } from './parser.js'; | |
| 2 | +import { Parser, inferPathKey, mapChildren } from './parser.js'; | |
| 3 | 3 | import { SqlSyntaxError, SqlEvaluationError } from './errors.js'; |
| 4 | 4 | import { evaluate } from './evaluate.js'; |
| 5 | +import { aggregates, isAggregateName } from './aggregates.js'; | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * @typedef {{kind: 'Star'}} StarSelect |
| @@ -14,7 +15,9 @@ import { evaluate } from './evaluate.js'; | ||
| 14 | 15 | * @typedef {{type: 'INNER'|'LEFT'|'CROSS', item: FromItem, on: import('./parser.js').Expr|null}} JoinClause |
| 15 | 16 | * @typedef {{first: FromItem, joins: JoinClause[]}} From |
| 16 | 17 | * @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 | + * @typedef {{type: 'Query', select: Select, from: From, where: import('./parser.js').Expr|null, groupBy: import('./parser.js').Expr[]|null, having: import('./parser.js').Expr|null, orderBy: OrderItem[]|null, limit: import('./parser.js').Expr|null}} Query | |
| 19 | + * @typedef {{key: string, fold: (values: any[]) => any, arg: import('./parser.js').Expr|import('./parser.js').StarNode}} PlannedAggregate | |
| 20 | + * @typedef {{select: Select, having: import('./parser.js').Expr|null, orderBy: OrderItem[]|null, groupBy: import('./parser.js').Expr[]|null, aggregates: PlannedAggregate[], grouped: boolean}} Plan | |
| 18 | 21 | */ |
| 19 | 22 | |
| 20 | 23 | class QueryParser extends Parser { |
| @@ -29,6 +32,17 @@ class QueryParser extends Parser { | ||
| 29 | 32 | this.next(); |
| 30 | 33 | where = this.parseOr(); |
| 31 | 34 | } |
| 35 | + let groupBy = null; | |
| 36 | + if (this.check('GROUP')) { | |
| 37 | + this.next(); | |
| 38 | + this.expect('BY'); | |
| 39 | + groupBy = this.parseGroupByList(); | |
| 40 | + } | |
| 41 | + let having = null; | |
| 42 | + if (this.check('HAVING')) { | |
| 43 | + this.next(); | |
| 44 | + having = this.parseOr(); | |
| 45 | + } | |
| 32 | 46 | let orderBy = null; |
| 33 | 47 | if (this.check('ORDER')) { |
| 34 | 48 | this.next(); |
| @@ -41,7 +55,23 @@ class QueryParser extends Parser { | ||
| 41 | 55 | limit = this.parseOr(); |
| 42 | 56 | } |
| 43 | 57 | this.expect('eof'); |
| 44 | - return { type: 'Query', select, from, where, orderBy, limit }; | |
| 58 | + /** @type {Query} */ | |
| 59 | + const ast = { type: 'Query', select, from, where, groupBy, having, orderBy, limit }; | |
| 60 | + // Planning is what rejects misplaced/nested aggregates and SELECT * with | |
| 61 | + // GROUP BY; running it here surfaces those at parse time. The result is | |
| 62 | + // recomputed (cheaply) by executeQuery, which also accepts hand-built ASTs. | |
| 63 | + planQuery(ast); | |
| 64 | + return ast; | |
| 65 | + } | |
| 66 | + | |
| 67 | + /** @returns {import('./parser.js').Expr[]} */ | |
| 68 | + parseGroupByList() { | |
| 69 | + const items = [this.parseOr()]; | |
| 70 | + while (this.check(',')) { | |
| 71 | + this.next(); | |
| 72 | + items.push(this.parseOr()); | |
| 73 | + } | |
| 74 | + return items; | |
| 45 | 75 | } |
| 46 | 76 | |
| 47 | 77 | /** @returns {OrderItem[]} */ |
| @@ -177,6 +207,129 @@ class QueryParser extends Parser { | ||
| 177 | 207 | } |
| 178 | 208 | |
| 179 | 209 | /** |
| 210 | + * Works out how a query has to be run: which aggregates it computes, what the | |
| 211 | + * SELECT/HAVING/ORDER BY expressions look like once those aggregates have been | |
| 212 | + * lifted out, and whether the query is grouped at all. Pure - it never mutates | |
| 213 | + * `ast`, so a parsed query can be executed repeatedly. | |
| 214 | + * @param {Query} ast | |
| 215 | + * @returns {Plan} | |
| 216 | + */ | |
| 217 | +function planQuery(ast) { | |
| 218 | + assertNoAggregates(ast.where, 'WHERE'); | |
| 219 | + assertNoAggregates(ast.from.first.expr, 'FROM'); | |
| 220 | + for (const expr of ast.groupBy ?? []) assertNoAggregates(expr, 'GROUP BY'); | |
| 221 | + for (const join of ast.from.joins) { | |
| 222 | + assertNoAggregates(join.item.expr, 'JOIN'); | |
| 223 | + assertNoAggregates(join.on, 'JOIN ... ON'); | |
| 224 | + } | |
| 225 | + | |
| 226 | + /** @type {PlannedAggregate[]} */ | |
| 227 | + const found = []; | |
| 228 | + const select = mapSelect(ast.select, (expr) => extractAggregates(expr, found)); | |
| 229 | + const having = ast.having === null ? null : extractAggregates(ast.having, found); | |
| 230 | + const orderBy = | |
| 231 | + ast.orderBy === null | |
| 232 | + ? null | |
| 233 | + : resolveOrderAliases(ast.orderBy, ast.select).map((item) => ({ | |
| 234 | + ...item, | |
| 235 | + expr: extractAggregates(item.expr, found), | |
| 236 | + })); | |
| 237 | + | |
| 238 | + const grouped = ast.groupBy !== null || found.length > 0; | |
| 239 | + if (grouped && ast.select.kind === 'Star') { | |
| 240 | + throw new SqlSyntaxError('SELECT * cannot be combined with GROUP BY or aggregate functions; list the columns instead'); | |
| 241 | + } | |
| 242 | + if (ast.having !== null && !grouped) { | |
| 243 | + throw new SqlSyntaxError('HAVING requires GROUP BY or an aggregate function; use WHERE to filter rows'); | |
| 244 | + } | |
| 245 | + | |
| 246 | + return { select, having, orderBy, groupBy: ast.groupBy, aggregates: found, grouped }; | |
| 247 | +} | |
| 248 | + | |
| 249 | +/** | |
| 250 | + * Replaces every aggregate call in `node` with a reference to a generated `$aggN` | |
| 251 | + * binding, recording in `sink` how to compute it. `$` is not a legal identifier | |
| 252 | + * character in the lexer, so these names can never collide with anything the | |
| 253 | + * query itself could have written. | |
| 254 | + * @param {import('./parser.js').Expr} node | |
| 255 | + * @param {PlannedAggregate[]} sink | |
| 256 | + * @returns {import('./parser.js').Expr} | |
| 257 | + */ | |
| 258 | +function extractAggregates(node, sink) { | |
| 259 | + if (node.type === 'Call' && isAggregateName(node.name)) { | |
| 260 | + const name = node.name.toUpperCase(); | |
| 261 | + if (node.args.length !== 1) { | |
| 262 | + throw new SqlSyntaxError(`${name} takes exactly one argument`); | |
| 263 | + } | |
| 264 | + const [arg] = node.args; | |
| 265 | + if (arg.type !== 'Star' && containsAggregate(arg)) { | |
| 266 | + throw new SqlSyntaxError('Aggregate functions cannot be nested'); | |
| 267 | + } | |
| 268 | + const key = `$agg${sink.length}`; | |
| 269 | + sink.push({ key, fold: aggregates[name], arg }); | |
| 270 | + return { type: 'Identifier', name: key }; | |
| 271 | + } | |
| 272 | + return mapChildren(node, (child) => extractAggregates(child, sink)); | |
| 273 | +} | |
| 274 | + | |
| 275 | +/** @param {import('./parser.js').Expr} node */ | |
| 276 | +function containsAggregate(node) { | |
| 277 | + if (node.type === 'Call' && isAggregateName(node.name)) return true; | |
| 278 | + let found = false; | |
| 279 | + mapChildren(node, (child) => { | |
| 280 | + if (containsAggregate(child)) found = true; | |
| 281 | + return child; | |
| 282 | + }); | |
| 283 | + return found; | |
| 284 | +} | |
| 285 | + | |
| 286 | +/** @param {import('./parser.js').Expr|null} node @param {string} clause */ | |
| 287 | +function assertNoAggregates(node, clause) { | |
| 288 | + if (node !== null && containsAggregate(node)) { | |
| 289 | + throw new SqlSyntaxError(`Aggregate functions are not allowed in ${clause}`); | |
| 290 | + } | |
| 291 | +} | |
| 292 | + | |
| 293 | +/** | |
| 294 | + * @param {Select} select | |
| 295 | + * @param {(expr: import('./parser.js').Expr) => import('./parser.js').Expr} fn | |
| 296 | + * @returns {Select} | |
| 297 | + */ | |
| 298 | +function mapSelect(select, fn) { | |
| 299 | + if (select.kind === 'Star') return select; | |
| 300 | + if (select.kind === 'Shape') { | |
| 301 | + return { kind: 'Shape', expr: /** @type {import('./parser.js').ObjectLiteralNode} */ (fn(select.expr)) }; | |
| 302 | + } | |
| 303 | + return { kind: 'Columns', items: select.items.map((item) => ({ ...item, expr: fn(item.expr) })) }; | |
| 304 | +} | |
| 305 | + | |
| 306 | +/** | |
| 307 | + * Lets ORDER BY name an output column, as SQL does, so `COUNT(*) AS aantal ... | |
| 308 | + * ORDER BY aantal DESC` sorts by the aggregate instead of silently finding | |
| 309 | + * nothing. Only bare identifiers are resolved - `ORDER BY p.aantal` stays a path | |
| 310 | + * into the row. | |
| 311 | + * @param {OrderItem[]} orderBy | |
| 312 | + * @param {Select} select | |
| 313 | + * @returns {OrderItem[]} | |
| 314 | + */ | |
| 315 | +function resolveOrderAliases(orderBy, select) { | |
| 316 | + const outputs = selectOutputs(select); | |
| 317 | + if (outputs === null) return orderBy; | |
| 318 | + return orderBy.map((item) => { | |
| 319 | + if (item.expr.type !== 'Identifier') return item; | |
| 320 | + const aliased = outputs.get(item.expr.name); | |
| 321 | + return aliased === undefined ? item : { ...item, expr: aliased }; | |
| 322 | + }); | |
| 323 | +} | |
| 324 | + | |
| 325 | +/** @param {Select} select @returns {Map<string, import('./parser.js').Expr>|null} */ | |
| 326 | +function selectOutputs(select) { | |
| 327 | + if (select.kind === 'Star') return null; | |
| 328 | + if (select.kind === 'Shape') return new Map(select.expr.properties.map((p) => [p.key, p.value])); | |
| 329 | + return new Map(select.items.map((item) => [item.key, item.expr])); | |
| 330 | +} | |
| 331 | + | |
| 332 | +/** | |
| 180 | 333 | * @param {FromItem} first |
| 181 | 334 | * @param {JoinClause[]} joins |
| 182 | 335 | */ |
| @@ -196,7 +349,8 @@ function validateFromAliases(first, joins) { | ||
| 196 | 349 | } |
| 197 | 350 | |
| 198 | 351 | /** |
| 199 | - * Parses a `SELECT ... FROM ... [WHERE ...] [ORDER BY ...] [LIMIT ...]` query string into an AST. | |
| 352 | + * Parses a `SELECT ... FROM ... [WHERE ...] [GROUP BY ...] [HAVING ...] [ORDER BY ...] [LIMIT ...]` | |
| 353 | + * query string into an AST. | |
| 200 | 354 | * @param {string} source |
| 201 | 355 | * @returns {Query} |
| 202 | 356 | */ |
| @@ -243,6 +397,8 @@ export function executeQuery(ast, context, options = {}) { | ||
| 243 | 397 | rows = nextRows; |
| 244 | 398 | } |
| 245 | 399 | |
| 400 | + const plan = planQuery(ast); | |
| 401 | + | |
| 246 | 402 | /** @type {{ctx: any, pristineRow: any}[]} */ |
| 247 | 403 | let matched = []; |
| 248 | 404 | for (const r of rows) { |
| @@ -250,8 +406,15 @@ export function executeQuery(ast, context, options = {}) { | ||
| 250 | 406 | matched.push(r); |
| 251 | 407 | } |
| 252 | 408 | |
| 253 | - if (ast.orderBy) { | |
| 254 | - const comparator = makeRowComparator(ast.orderBy, options); | |
| 409 | + if (plan.grouped) { | |
| 410 | + matched = groupRows(matched, plan, context, options); | |
| 411 | + if (plan.having) { | |
| 412 | + matched = matched.filter((r) => evaluate(plan.having, r.ctx, options) === true); | |
| 413 | + } | |
| 414 | + } | |
| 415 | + | |
| 416 | + if (plan.orderBy) { | |
| 417 | + const comparator = makeRowComparator(plan.orderBy, options); | |
| 255 | 418 | matched = matched.slice().sort(comparator); |
| 256 | 419 | } |
| 257 | 420 | |
| @@ -266,11 +429,54 @@ export function executeQuery(ast, context, options = {}) { | ||
| 266 | 429 | } |
| 267 | 430 | |
| 268 | 431 | return matched.map((r) => |
| 269 | - ast.select.kind === 'Star' && !hasJoins ? r.pristineRow : project(ast.select, r.ctx, options), | |
| 432 | + plan.select.kind === 'Star' && !hasJoins ? r.pristineRow : project(plan.select, r.ctx, options), | |
| 270 | 433 | ); |
| 271 | 434 | } |
| 272 | 435 | |
| 273 | 436 | /** |
| 437 | + * Folds the matched rows into one row per distinct GROUP BY key - or a single row | |
| 438 | + * when the query has no GROUP BY but does use aggregates. A group's context is its | |
| 439 | + * first row's context plus the computed `$aggN` bindings, so expressions that | |
| 440 | + * aren't aggregated still resolve: against an arbitrary row of the group, the way | |
| 441 | + * SQLite treats bare columns, rather than being rejected outright. | |
| 442 | + * @param {{ctx: any, pristineRow: any}[]} rows | |
| 443 | + * @param {Plan} plan | |
| 444 | + * @param {Record<string, any>} context | |
| 445 | + * @param {{functions?: Record<string, (...args: any[]) => any>}} options | |
| 446 | + * @returns {{ctx: any, pristineRow: any}[]} | |
| 447 | + */ | |
| 448 | +function groupRows(rows, plan, context, options) { | |
| 449 | + /** @type {Map<string, {ctx: any, pristineRow: any}[]>} */ | |
| 450 | + const groups = new Map(); | |
| 451 | + for (const r of rows) { | |
| 452 | + // Serializing the key values is what makes grouping by an object or array | |
| 453 | + // (not just a scalar) work at all; it costs key-order sensitivity, which | |
| 454 | + // only shows up for objects built with their keys in differing orders. | |
| 455 | + const key = plan.groupBy === null ? '' : JSON.stringify(plan.groupBy.map((e) => evaluate(e, r.ctx, options))); | |
| 456 | + const group = groups.get(key); | |
| 457 | + if (group) group.push(r); | |
| 458 | + else groups.set(key, [r]); | |
| 459 | + } | |
| 460 | + | |
| 461 | + // `SELECT COUNT(*) AS n FROM empty` is still one row (n = 0), but grouping an | |
| 462 | + // empty set of rows yields no groups at all. | |
| 463 | + if (plan.groupBy === null && groups.size === 0) groups.set('', []); | |
| 464 | + | |
| 465 | + const out = []; | |
| 466 | + for (const group of groups.values()) { | |
| 467 | + const ctx = { ...(group.length > 0 ? group[0].ctx : context) }; | |
| 468 | + for (const agg of plan.aggregates) { | |
| 469 | + // COUNT(*) has no expression to evaluate; a non-null placeholder per row | |
| 470 | + // is what turns "count non-nulls" into "count rows". | |
| 471 | + const values = group.map((r) => (agg.arg.type === 'Star' ? true : evaluate(agg.arg, r.ctx, options))); | |
| 472 | + ctx[agg.key] = agg.fold(values); | |
| 473 | + } | |
| 474 | + out.push({ ctx, pristineRow: undefined }); | |
| 475 | + } | |
| 476 | + return out; | |
| 477 | +} | |
| 478 | + | |
| 479 | +/** | |
| 274 | 480 | * @param {FromItem} item |
| 275 | 481 | * @param {Record<string, any>} scopeCtx |
| 276 | 482 | * @param {{functions?: Record<string, (...args: any[]) => any>}} options |
| @@ -359,7 +565,8 @@ function project(select, ctx, options) { | ||
| 359 | 565 | } |
| 360 | 566 | |
| 361 | 567 | /** |
| 362 | - * Parses (if needed) and executes a `SELECT ... FROM ... [WHERE ...] [ORDER BY ...] [LIMIT ...]` query. | |
| 568 | + * Parses (if needed) and executes a | |
| 569 | + * `SELECT ... FROM ... [WHERE ...] [GROUP BY ...] [HAVING ...] [ORDER BY ...] [LIMIT ...]` query. | |
| 363 | 570 | * @param {string|Query} source - query text, or an already-parsed AST |
| 364 | 571 | * @param {Record<string, any>} context |
| 365 | 572 | * @param {{functions?: Record<string, (...args: any[]) => any>}} [options] |