Add GROUP BY support to the Query page, and render null and boolean results

Re-syncs the vendored jsonql-js copy with the upstream module, which gains GROUP BY, HAVING and the COUNT/SUM/AVG/MIN/MAX/ARRAY_AGG aggregates. Grouping also exposed two holes in the results renderer. renderValue had no branch for null, so it fell through every case and returned undefined, making appendChild throw - unavoidable now that MIN over an all-null group or a LEFT JOIN fill can put a null in the output. Booleans built a span carrying only a colour class and no text, so they rendered as nothing at all; grouping by a predicate (`GROUP BY age >= 18`) puts them in the results, so they need to be legible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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(--info);
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]