@omgbase/oqx 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +42 -2
  3. package/dist/ast.d.ts +35 -1
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +7 -1
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +7 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +9 -2
  10. package/dist/build.js.map +1 -1
  11. package/dist/engine.d.ts +2 -0
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +136 -9
  14. package/dist/engine.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/optimize/analysis.d.ts.map +1 -1
  20. package/dist/optimize/analysis.js +15 -1
  21. package/dist/optimize/analysis.js.map +1 -1
  22. package/dist/optimize/ir.d.ts +5 -1
  23. package/dist/optimize/ir.d.ts.map +1 -1
  24. package/dist/optimize/ir.js +2 -0
  25. package/dist/optimize/ir.js.map +1 -1
  26. package/dist/optimize/rules.d.ts.map +1 -1
  27. package/dist/optimize/rules.js +4 -3
  28. package/dist/optimize/rules.js.map +1 -1
  29. package/dist/parser.d.ts.map +1 -1
  30. package/dist/parser.js +402 -20
  31. package/dist/parser.js.map +1 -1
  32. package/dist/print.d.ts.map +1 -1
  33. package/dist/print.js +16 -0
  34. package/dist/print.js.map +1 -1
  35. package/dist/references.d.ts +6 -4
  36. package/dist/references.d.ts.map +1 -1
  37. package/dist/references.js +65 -3
  38. package/dist/references.js.map +1 -1
  39. package/dist/resolve.d.ts.map +1 -1
  40. package/dist/resolve.js +20 -1
  41. package/dist/resolve.js.map +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/dist/walk.d.ts +1 -1
  45. package/dist/walk.d.ts.map +1 -1
  46. package/dist/walk.js +10 -0
  47. package/dist/walk.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/ast.ts +44 -2
  50. package/src/build.ts +11 -4
  51. package/src/engine.ts +136 -9
  52. package/src/index.ts +1 -1
  53. package/src/optimize/analysis.ts +12 -1
  54. package/src/optimize/ir.ts +7 -1
  55. package/src/optimize/rules.ts +4 -3
  56. package/src/parser.ts +324 -20
  57. package/src/print.ts +18 -3
  58. package/src/references.ts +62 -7
  59. package/src/resolve.ts +22 -2
  60. package/src/version.ts +1 -1
  61. package/src/walk.ts +12 -1
package/src/parser.ts CHANGED
@@ -83,8 +83,9 @@ import { lexTemplate, lexString } from "./lexer.ts";
83
83
  import { OqxError } from "./errors.ts";
84
84
  import type {
85
85
  Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
86
- BinaryOp, Span, TemplatePart,
86
+ BinaryOp, Span, TemplatePart, GroupBy, GroupKey, AggregateName,
87
87
  } from "./ast.ts";
88
+ import { AGGREGATE_NAMES, isAggregateName } from "./ast.ts";
88
89
  import { resolveSelect, resolveWhere } from "./resolve.ts";
89
90
  import { printTemplate } from "./print.ts";
90
91
 
@@ -93,8 +94,10 @@ const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first"
93
94
  // `frontier` is a clause of every body that has a `follow` (a destination
94
95
  // block's marks the occurrences it reaches, any other body's its seeds — 0.20);
95
96
  // `depth` is legal only in a follow destination block, and the order sentence
96
- // names it only there.
97
- const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "order by", "limit", "offset"] as const;
97
+ // names it only there. `group by` and `having` (0.24) sit after `follow`: the
98
+ // rows a body yields — the walk's occurrences, when it has one — are what gets
99
+ // grouped, and the bound and the ordering then apply to the groups.
100
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "group by", "having", "order by", "limit", "offset"] as const;
98
101
  type Clause = (typeof CLAUSE_ORDER)[number];
99
102
  const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
100
103
  // The clauses a body may hold, in order.
@@ -106,8 +109,11 @@ function clausesOf(ctx: BodyCtx): Clause[] {
106
109
  const CLAUSE_WORDS = new Set<string>([
107
110
  "collect", "exists", "none", "count", "first", "single",
108
111
  "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
109
- "limit", "offset",
112
+ "limit", "offset", "group", "having",
110
113
  ]);
114
+ // The names a group scope resolves besides its keys (SEMANTICS §24): the
115
+ // group's rows, the key record itself, and its identity.
116
+ const GROUP_SCOPE_NAMES: ReadonlySet<string> = new Set(["rows", "$it", "$key"]);
111
117
  const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
112
118
  // The comparison level (GRAMMAR §4): the six relational operators and, since
113
119
  // 0.22, the four regex operators — sugar over `matches`, non-associative like
@@ -152,6 +158,8 @@ interface BodyClauses {
152
158
  follow: Follow | null;
153
159
  frontier: Expr | null;
154
160
  depth: number | null;
161
+ groupBy: GroupBy | null;
162
+ having: Where | null;
155
163
  distinct: boolean;
156
164
  values: boolean;
157
165
  limit: Expr | null;
@@ -265,6 +273,8 @@ class Parser {
265
273
  consumer: "collect",
266
274
  follow: body.follow,
267
275
  frontier: body.frontier,
276
+ groupBy: body.groupBy,
277
+ having: body.having,
268
278
  distinct: body.distinct,
269
279
  values: body.values,
270
280
  limit: body.limit,
@@ -277,8 +287,8 @@ class Parser {
277
287
  private queryFromOp(e: OpNode, start: number): Query {
278
288
  return {
279
289
  kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
280
- orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
281
- limit: e.sub.limit, offset: e.sub.offset,
290
+ orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, groupBy: e.sub.groupBy, having: e.sub.having,
291
+ distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
282
292
  };
283
293
  }
284
294
 
@@ -300,6 +310,8 @@ class Parser {
300
310
  let follow: Follow | null = null;
301
311
  let frontier: Expr | null = null;
302
312
  let depth: number | null = null;
313
+ let groupBy: GroupBy | null = null;
314
+ let having: Where | null = null;
303
315
  let distinct = false;
304
316
  let values = false;
305
317
  let limit: Expr | null = null;
@@ -365,6 +377,20 @@ class Parser {
365
377
  follow = this.atDepth(rowDepth, () => this.parseFollow());
366
378
  continue;
367
379
  }
380
+ // `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
381
+ if (this.atGroupBy()) {
382
+ enter("group by");
383
+ groupBy = this.atDepth(rowDepth, () => this.parseGroupBy());
384
+ continue;
385
+ }
386
+ // `having P` (0.24): a predicate over each GROUP, read at the row depth
387
+ // (the group scope sits where the rows did).
388
+ if (this.atHaving()) {
389
+ enter("having");
390
+ this.next();
391
+ having = this.atDepth(rowDepth, () => this.parseWhere());
392
+ continue;
393
+ }
368
394
  if (this.atOrderBy()) {
369
395
  enter("order by");
370
396
  this.next(); this.next(); // `order` `by`
@@ -404,14 +430,213 @@ class Parser {
404
430
  // Validate the alias references of this body (cycles, a block alias inside a
405
431
  // `where` expression, a `select` item naming an item to its right); the
406
432
  // surface form is kept. `where` first: its cycle report follows its own path.
407
- if (where) resolveWhere(select, where, (msg) => this.fail(msg));
408
- resolveSelect(select, (msg) => this.fail(msg));
433
+ // A grouped body without a projection yields its keys and a count (0.24);
434
+ // the items are synthesized before the alias validation sees the select.
435
+ if (groupBy && select.length === 0) select = defaultGroupProjection(groupBy, (msg) => this.failAt(groupBy!.span[0], msg));
436
+ const resolvedWhere = where ? resolveWhere(select, where, (msg) => this.fail(msg)) : null;
437
+ const resolvedHaving = having ? resolveWhere(select, having, (msg) => this.fail(msg)) : null;
438
+ // The resolved items: an aggregate reached through an alias (`n: count(),
439
+ // xs: collect(n)`) is judged where it will be evaluated.
440
+ const resolvedSelect = resolveSelect(select, (msg) => this.fail(msg));
409
441
  // Outside a destination block `frontier` marks the seeds of this body's walk,
410
442
  // so it needs one.
411
443
  if (frontier && !follow && !ctx.destination) {
412
444
  this.fail("`frontier` needs a `follow` — a body's `frontier <expr>` marks which of its rows (the walk's seeds) are frontiers; a destination block's marks the occurrences reached through it");
413
445
  }
414
- return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
446
+ // Grouping (0.24): `having` needs a `group by`; `distinct` is redundant with
447
+ // one; aggregates may stand only in the post-group clauses; and every bare
448
+ // name read at the group scope must be a key, an alias, or the group's rows.
449
+ if (having && !groupBy) {
450
+ this.failAt(having.span[0], "`having` needs a `group by` — `having <pred>` filters the groups a body yields; a per-row filter is `where`");
451
+ }
452
+ if (groupBy) {
453
+ if (distinct) this.failAt(groupBy.span[0], DISTINCT_WITH_GROUP);
454
+ this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy });
455
+ } else {
456
+ this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy: null });
457
+ }
458
+ return { froms, where, select, orderBy, follow, frontier, depth, groupBy, having, distinct, values, limit, offset };
459
+ }
460
+
461
+ // ---- grouping (0.24) ------------------------------------------------------
462
+
463
+ // `group by` is a clause only as the two-word sequence (a field named `group`
464
+ // stays a name: `select group from r`).
465
+ private atGroupBy(): boolean {
466
+ const t = this.peek();
467
+ const nx = this.peekAt(1);
468
+ return t.type === "ident" && t.value === "group" && !!nx && nx.type === "ident" && nx.value === "by";
469
+ }
470
+
471
+ // `having` is a clause when a predicate can follow it — a value start, or an
472
+ // aggregate call whose name is a consumer word (`having count() > 1`:
473
+ // `count` alone cannot begin a value, `count(` can); otherwise a field name.
474
+ private atHaving(): boolean {
475
+ if (!this.at("ident", "having")) return false;
476
+ const nx = this.peekAt(1);
477
+ return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
478
+ }
479
+
480
+ private parseGroupBy(): GroupBy {
481
+ const start = this.start();
482
+ this.next(); this.next(); // `group` `by`
483
+ const keys = [this.parseGroupKey()];
484
+ while (this.at("comma")) { this.next(); keys.push(this.parseGroupKey()); }
485
+ const seen = new Set<string>();
486
+ for (const k of keys) {
487
+ if (seen.has(k.name)) this.failAt(k.span[0], `duplicate group key '${k.name}' — each key needs its own name (alias one: \`other: expr\`)`);
488
+ seen.add(k.name);
489
+ }
490
+ return { kind: "groupBy", span: this.sp(start), keys };
491
+ }
492
+
493
+ // One key, shaped like a select item: `name: expr`, or an unaliased navigation
494
+ // keyed by its last segment; any other expression needs the alias, because the
495
+ // alias is how the post-group clauses refer to the key.
496
+ private parseGroupKey(): GroupKey {
497
+ const start = this.start();
498
+ if (this.at("ident") && this.peekAt(1)?.type === "colon") {
499
+ const name = this.next().value;
500
+ this.next(); // ':'
501
+ const expr = this.parseValueExpr();
502
+ return { kind: "key", span: this.sp(start), name, expr };
503
+ }
504
+ if (!this.canStartValue()) this.fail("expected a group key after `group by` (`group by <name>` or `group by <alias>: <expr>`)");
505
+ const expr = this.parseValueExpr();
506
+ const name = navKey(expr);
507
+ if (name === null) {
508
+ this.failAt(start, `a group key that is not a plain name needs an alias (\`group by key: ${describe(expr)}\`) — the alias is the name the select, \`having\` and \`order by\` read it by`);
509
+ }
510
+ return { kind: "key", span: this.sp(start), name, expr };
511
+ }
512
+
513
+ // `name(` where `name` is an aggregate: `count()`, `sum(e)`, `collect(distinct
514
+ // e)`. The cursor is at the `(`. Arity and the `distinct` modifier are checked
515
+ // here; WHERE the aggregate may stand is the body's business (`checkGroupedBody`).
516
+ private parseAggregate(name: AggregateName, start: number): Expr {
517
+ this.next(); // '('
518
+ let distinct = false;
519
+ if (this.at("ident", "distinct") && this.startsValue(this.peekAt(1))) { this.next(); distinct = true; }
520
+ let arg: Expr | null = null;
521
+ if (!this.at("rparen")) {
522
+ arg = this.parseValueExpr();
523
+ if (this.at("comma")) this.fail(`\`${name}(…)\` takes one argument — an aggregate reduces one expression over the group's rows`);
524
+ }
525
+ if (!this.at("rparen")) this.fail(`expected ')' to close \`${name}(…)\``);
526
+ this.next();
527
+ if (arg === null && name !== "count") this.fail(`\`${name}()\` needs an argument (\`${name}(<expr>)\`) — only \`count()\` counts the group's rows without one`);
528
+ if (distinct && (name === "any" || name === "all")) {
529
+ this.fail(`\`${name}(distinct …)\`: \`distinct\` applies to a value aggregate (count, sum, min, max, avg, collect, first, last), not to a predicate aggregate`);
530
+ }
531
+ return { kind: "aggregate", span: [start, this.end()], name, arg, distinct };
532
+ }
533
+
534
+ // A free call: an aggregate node for an aggregate name (reserved since 0.24),
535
+ // an ordinary `call` otherwise. The cursor is at the `(`.
536
+ private parseFreeCall(name: string, start: number): Expr {
537
+ if (isAggregateName(name)) return this.parseAggregate(name, start);
538
+ const args = this.parseArgs();
539
+ return { kind: "call", span: this.sp(start), recv: null, name, args };
540
+ }
541
+
542
+ // The clauses an aggregate may NOT stand in — every clause read before the
543
+ // rows are grouped (`from`, `where`, `frontier`, the keys, the bound) — and,
544
+ // in a body without `group by`, every clause. The `where` is the alias-resolved
545
+ // one, so an aggregate reached through a select alias is caught too.
546
+ private rejectAggregates(b: GroupCheck): void {
547
+ const found: [Expr, string][] = [];
548
+ const collect = (exprs: (Expr | null)[], clause: string): void => {
549
+ const out: Expr[] = [];
550
+ for (const e of exprs) if (e) aggregatesIn(e, out);
551
+ for (const a of out) found.push([a, clause]);
552
+ };
553
+ const where: Expr[] = [];
554
+ if (b.where) whereAggregates(b.where, where);
555
+ for (const a of where) found.push([a, "`where`"]);
556
+ collect(b.froms, "`from`");
557
+ collect([b.frontier], "`frontier`");
558
+ collect([b.limit], "`limit`");
559
+ collect([b.offset], "`offset`");
560
+ if (b.groupBy) collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
561
+ if (!b.groupBy) {
562
+ collect(b.resolvedSelect.filter((it) => it.kind === "field").map((it) => (it as Extract<SelectItem, { kind: "field" }>).expr), "a select item");
563
+ if (b.orderBy) collect(b.orderBy.map((o) => o.expr), "`order by`");
564
+ }
565
+ const hit = found[0];
566
+ if (!hit) return;
567
+ const [agg, clause] = hit;
568
+ if (!b.groupBy) {
569
+ this.failAt(agg.span[0], `\`${describe(agg)}\` is an aggregate: it is read after grouping and may stand only in a select item, \`having\` or \`order by\` of a body with \`group by\` — this body has none`);
570
+ }
571
+ this.failAt(agg.span[0], `\`${describe(agg)}\` is an aggregate and cannot be read in ${clause}${clause === "`where`" ? " (a select alias standing for one included)" : ""} — the rows are not grouped yet there; aggregates stand in a select item, \`having\` or \`order by\``);
572
+ }
573
+
574
+ // A grouped body: aggregates only after the grouping (and never nested), and
575
+ // every bare name at the group scope a key, an alias, or the group's rows.
576
+ private checkGroupedBody(b: GroupCheck): void {
577
+ this.rejectAggregates(b);
578
+ // An aggregate's argument is read on the group's rows, where there is no
579
+ // group to aggregate: `sum(count())` is an error, not a double reduction.
580
+ const post: Expr[] = [];
581
+ for (const it of b.resolvedSelect) if (it.kind === "field") aggregatesIn(it.expr, post);
582
+ if (b.resolvedHaving) whereAggregates(b.resolvedHaving, post);
583
+ if (b.orderBy) for (const o of b.orderBy) aggregatesIn(o.expr, post);
584
+ const nested = nestedAggregate(post);
585
+ if (nested) this.failAt(nested.span[0], `\`${describe(nested)}\`: an aggregate cannot nest — its argument is read on each of the group's rows, where there is no group to reduce`);
586
+ const keys = new Set(b.groupBy!.keys.map((k) => k.name));
587
+ const valid = (aliases: Iterable<string>): ReadonlySet<string> => new Set([...keys, ...GROUP_SCOPE_NAMES, ...aliases]);
588
+ const aliases: string[] = [];
589
+ for (const it of b.select) {
590
+ const names = valid(aliases);
591
+ if (it.kind === "field") this.checkGroupExpr(it.expr, names, "a select item");
592
+ else this.checkGroupExpr(it.op.receiver, names, "a select item");
593
+ const own = it.kind === "collect" ? (it.name === "" ? null : it.name) : it.lift === 0 && it.name !== "" ? it.name : null;
594
+ if (own !== null) aliases.push(own);
595
+ }
596
+ if (b.having) this.checkGroupWhere(b.having, valid(aliases), "`having`");
597
+ if (b.orderBy) for (const o of b.orderBy) this.checkGroupExpr(o.expr, valid([]), "`order by`");
598
+ }
599
+
600
+ private checkGroupWhere(w: Where, valid: ReadonlySet<string>, where: string): void {
601
+ switch (w.kind) {
602
+ case "and": case "or": w.parts.forEach((p) => this.checkGroupWhere(p, valid, where)); return;
603
+ case "not": this.checkGroupWhere(w.expr, valid, where); return;
604
+ case "scalar": this.checkGroupExpr(w.expr, valid, where); return;
605
+ case "op": this.checkGroupExpr(w.receiver, valid, where); return; // the block is its own scope
606
+ }
607
+ }
608
+
609
+ // Every bare name `e` reads at the group scope (an aggregate's argument reads
610
+ // the group's rows and is not checked; a nested block is its own scope, only
611
+ // its receiver is read here). `inCall` names the enclosing non-aggregate free
612
+ // call, for the "not an aggregate" hint.
613
+ private checkGroupExpr(e: Expr, valid: ReadonlySet<string>, where: string, inCall: string | null = null): void {
614
+ switch (e.kind) {
615
+ case "ident":
616
+ if (!valid.has(e.name)) {
617
+ const hint = inCall === null ? "" : `; \`${inCall}(…)\` is not an aggregate — the aggregates are ${[...AGGREGATE_NAMES].join(", ")}`;
618
+ this.failAt(e.span[0], `\`${e.name}\` is not a group key or aggregate — ${where} of a grouped body reads the group: a key (\`group by ${e.name}\`), an aggregate over its rows (\`collect(${e.name})\`), or the rows themselves (\`rows collect { select ${e.name} }\`)${hint}`);
619
+ }
620
+ return;
621
+ case "lit": case "binding": case "outer": case "aggregate": return;
622
+ case "member": this.checkGroupExpr(e.recv, valid, where, inCall); return;
623
+ case "call":
624
+ if (e.recv) this.checkGroupExpr(e.recv, valid, where, inCall);
625
+ for (const a of e.args) this.checkGroupExpr(a, valid, where, e.recv === null ? e.name : inCall);
626
+ return;
627
+ case "unary": case "required": this.checkGroupExpr(e.expr, valid, where, inCall); return;
628
+ case "binary": case "logical": case "in":
629
+ this.checkGroupExpr(e.left, valid, where, inCall);
630
+ this.checkGroupExpr(e.right, valid, where, inCall);
631
+ return;
632
+ case "range":
633
+ if (e.lo) this.checkGroupExpr(e.lo, valid, where, inCall);
634
+ if (e.hi) this.checkGroupExpr(e.hi, valid, where, inCall);
635
+ return;
636
+ case "tuple": e.items.forEach((x) => this.checkGroupExpr(x, valid, where, inCall)); return;
637
+ case "template": e.parts.forEach((p) => { if (typeof p !== "string") this.checkGroupExpr(p, valid, where, inCall); }); return;
638
+ case "op": this.checkGroupExpr(e.receiver, valid, where, inCall); return;
639
+ }
415
640
  }
416
641
 
417
642
  // The keyword-less run that opens a body. At the top level it is always the
@@ -488,6 +713,8 @@ class Parser {
488
713
  }
489
714
  if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
490
715
  if (t.value === "frontier") this.fail(`unexpected 'frontier' after \`${last}\` — \`frontier\` takes an expression (\`frontier <expr>\`) and belongs to a body that has a \`follow\``);
716
+ if (t.value === "group") this.fail(`unexpected 'group' after \`${last}\` — a grouping is written \`group by <key>[, <key>…]\` (a key is a name or \`alias: <expr>\`)`);
717
+ if (t.value === "having") this.fail(`unexpected 'having' after \`${last}\` — \`having\` takes a predicate over each group (\`having count() > 1\`) and belongs to a body that has a \`group by\``);
491
718
  }
492
719
  if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
493
720
  this.fail(`unexpected \`${t.value}\` after \`from\` — a whole-query consumer is written \`<collection> ${t.value} { … }\`; to project a field named ${t.value} write \`select ${t.value} from …\`; a predicate needs \`where\``);
@@ -659,10 +886,7 @@ class Parser {
659
886
  ? { kind: "outer", span: [start, head.end], levels, name: head.value }
660
887
  : { kind: "ident", span: [start, head.end], name: head.value };
661
888
  let name = head.value;
662
- if (levels === 0 && this.at("lparen")) {
663
- const args = this.parseArgs();
664
- expr = { kind: "call", span: this.sp(start), recv: null, name: head.value, args };
665
- }
889
+ if (levels === 0 && this.at("lparen")) expr = this.parseFreeCall(head.value, start);
666
890
  while (this.at("dot")) {
667
891
  this.next();
668
892
  if (!this.at("ident")) this.fail("expected a property name after '.'");
@@ -728,8 +952,8 @@ class Parser {
728
952
  if (op) {
729
953
  // `name: jobs first { }.pay`: the directive is the head of a longer value.
730
954
  if (this.atScalarContinuation() || this.atValueDirective()) return this.finishItem(name, this.parseOr(op), lift, start);
731
- if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
732
- this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
955
+ if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
956
+ this.fail(`projection '${name}' must use collect/first/single/count, not ${op.op} (exists/none are where-position tests; \`count { … }\` is a value since 0.24)`);
733
957
  }
734
958
  return this.finishItem(name, op, lift, start);
735
959
  }
@@ -1010,6 +1234,9 @@ class Parser {
1010
1234
  this.deferLookup = defer;
1011
1235
  if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
1012
1236
  this.next();
1237
+ // `x collect distinct { group by k }`: the directive's distinct is as
1238
+ // redundant as the body's own (checked in parseBody).
1239
+ if (distinct && body.groupBy) this.failAt(body.groupBy.span[0], DISTINCT_WITH_GROUP);
1013
1240
  const sub: Subquery = {
1014
1241
  kind: "subquery",
1015
1242
  span: this.sp(lbrace),
@@ -1020,6 +1247,8 @@ class Parser {
1020
1247
  follow: body.follow,
1021
1248
  frontier: body.frontier,
1022
1249
  depth: body.depth,
1250
+ groupBy: body.groupBy,
1251
+ having: body.having,
1023
1252
  values: body.values,
1024
1253
  limit: body.limit,
1025
1254
  offset: body.offset,
@@ -1078,7 +1307,7 @@ class Parser {
1078
1307
  this.next();
1079
1308
  const subSpan: Span = [lbracket, this.end()];
1080
1309
  const sub = (parts: Partial<Subquery>): Subquery => ({
1081
- kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
1310
+ kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, groupBy: null, having: null, values: false, limit: null, offset: null, ...parts,
1082
1311
  });
1083
1312
  const bang = this.atOp("!") ? this.next() : null;
1084
1313
  if (index) {
@@ -1237,9 +1466,9 @@ class Parser {
1237
1466
  expr = { kind: "member", span: this.sp(start), recv: expr, name };
1238
1467
  }
1239
1468
  } else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
1240
- // free function call: name(args) — a bare name read here, not `(f)(x)`
1241
- const args = this.parseArgs();
1242
- expr = { kind: "call", span: this.sp(start), recv: null, name: expr.name, args };
1469
+ // free function call: name(args) — a bare name read here, not `(f)(x)`;
1470
+ // an aggregate name makes it an `aggregate` node (0.24)
1471
+ expr = this.parseFreeCall(expr.name, start);
1243
1472
  } else if (this.at("lbracket")) {
1244
1473
  expr = this.parseBracket(expr);
1245
1474
  } else if (this.atOp("!")) {
@@ -1262,9 +1491,12 @@ class Parser {
1262
1491
  return expr;
1263
1492
  }
1264
1493
 
1494
+ // The directives that have a value form: collect/first/single, and since 0.24
1495
+ // `count { … }` (the number of rows the block yields). exists/none are
1496
+ // predicates only.
1265
1497
  private atValueDirective(): boolean {
1266
1498
  const op = this.atConsumerBlock();
1267
- return op === "collect" || op === "first" || op === "single";
1499
+ return op === "collect" || op === "first" || op === "single" || op === "count";
1268
1500
  }
1269
1501
 
1270
1502
  private parseArgs(): Expr[] {
@@ -1355,6 +1587,78 @@ class Parser {
1355
1587
  }
1356
1588
  }
1357
1589
 
1590
+ // ---- grouping helpers (0.24) --------------------------------------------------
1591
+
1592
+ const DISTINCT_WITH_GROUP = "`distinct` is redundant with `group by` — a grouped body already yields one row per distinct key tuple; drop `distinct`";
1593
+
1594
+ // The clauses `checkGroupedBody` / `rejectAggregates` look at: the body's own
1595
+ // expressions, with `where` (and `resolvedHaving`) already alias-resolved.
1596
+ interface GroupCheck {
1597
+ select: SelectItem[];
1598
+ /** The items with their aliases substituted (`resolveSelect`), for the aggregate checks. */
1599
+ resolvedSelect: SelectItem[];
1600
+ having: Where | null;
1601
+ resolvedHaving: Where | null;
1602
+ orderBy: OrderSpec[] | null;
1603
+ froms: Expr[];
1604
+ where: Where | null;
1605
+ frontier: Expr | null;
1606
+ limit: Expr | null;
1607
+ offset: Expr | null;
1608
+ groupBy: GroupBy | null;
1609
+ }
1610
+
1611
+ // The projection of a grouped body that writes none (SEMANTICS §24): every key
1612
+ // by its name, then `count: count()`. Synthesized here so the tree shows what
1613
+ // the body projects and the engine has no grouped "row itself" to define; the
1614
+ // nodes carry the empty span (they did not come from source).
1615
+ function defaultGroupProjection(groupBy: GroupBy, fail: (msg: string) => never): SelectItem[] {
1616
+ if (groupBy.keys.some((k) => k.name === "count")) {
1617
+ fail("a group key named `count` needs an explicit `select` — the default projection of a grouped body is its keys plus `count: count()`");
1618
+ }
1619
+ const items: SelectItem[] = groupBy.keys.map((k) => ({ kind: "field", span: [0, 0], name: k.name, expr: { kind: "ident", span: [0, 0], name: k.name }, lift: 0 }));
1620
+ items.push({ kind: "field", span: [0, 0], name: "count", expr: { kind: "aggregate", span: [0, 0], name: "count", arg: null, distinct: false }, lift: 0 });
1621
+ return items;
1622
+ }
1623
+
1624
+ // Every aggregate node in `e` read at THIS scope (a nested block's body is its
1625
+ // own scope and is not entered; a block's receiver is). An aggregate's argument
1626
+ // is entered too, so a nested aggregate is found by `nestedAggregate`.
1627
+ function aggregatesIn(e: Expr, out: Expr[]): void {
1628
+ switch (e.kind) {
1629
+ case "aggregate": out.push(e); if (e.arg) aggregatesIn(e.arg, out); return;
1630
+ case "lit": case "ident": case "outer": case "binding": return;
1631
+ case "member": aggregatesIn(e.recv, out); return;
1632
+ case "call": if (e.recv) aggregatesIn(e.recv, out); e.args.forEach((a) => aggregatesIn(a, out)); return;
1633
+ case "unary": case "required": aggregatesIn(e.expr, out); return;
1634
+ case "binary": case "logical": case "in": aggregatesIn(e.left, out); aggregatesIn(e.right, out); return;
1635
+ case "range": if (e.lo) aggregatesIn(e.lo, out); if (e.hi) aggregatesIn(e.hi, out); return;
1636
+ case "tuple": e.items.forEach((x) => aggregatesIn(x, out)); return;
1637
+ case "template": e.parts.forEach((p) => { if (typeof p !== "string") aggregatesIn(p, out); }); return;
1638
+ case "op": aggregatesIn(e.receiver, out); return;
1639
+ }
1640
+ }
1641
+
1642
+ function whereAggregates(w: Where, out: Expr[]): void {
1643
+ switch (w.kind) {
1644
+ case "and": case "or": w.parts.forEach((p) => whereAggregates(p, out)); return;
1645
+ case "not": whereAggregates(w.expr, out); return;
1646
+ case "scalar": aggregatesIn(w.expr, out); return;
1647
+ case "op": aggregatesIn(w.receiver, out); return;
1648
+ }
1649
+ }
1650
+
1651
+ // The first aggregate whose argument holds another (`sum(count())`), if any.
1652
+ function nestedAggregate(found: readonly Expr[]): Expr | null {
1653
+ for (const a of found) {
1654
+ if (a.kind !== "aggregate" || !a.arg) continue;
1655
+ const inner: Expr[] = [];
1656
+ aggregatesIn(a.arg, inner);
1657
+ if (inner.length > 0) return a;
1658
+ }
1659
+ return null;
1660
+ }
1661
+
1358
1662
  // The default key of an unaliased projection item: the last segment of a bare /
1359
1663
  // dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
1360
1664
  function navKey(e: Expr): string | null {
package/src/print.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // binding is a value, never source text: `print` throws on one; `printTemplate`
13
13
  // emits template fragments around each binding instead.
14
14
 
15
- import type { AstNode, Expr, Follow, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
15
+ import type { AstNode, Expr, Follow, GroupBy, GroupKey, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
16
16
  import { OqxError } from "./errors.ts";
17
17
 
18
18
  /** The template form of a tree: `strings` has `count + 1` fragments; the i-th gap
@@ -67,7 +67,7 @@ const OR = 1, AND = 2, CMP = 3, RANGE = 4, ADD = 5, MUL = 6, UNARY = 7, POSTFIX
67
67
  function prec(e: Expr): number {
68
68
  switch (e.kind) {
69
69
  case "lit": case "ident": case "outer": case "binding": case "tuple": case "template": return PRIMARY;
70
- case "member": case "call": case "required": case "op": return POSTFIX;
70
+ case "member": case "call": case "aggregate": case "required": case "op": return POSTFIX;
71
71
  case "unary": return UNARY;
72
72
  // Comparison, identity and the regex operators (0.22) share the cmp level.
73
73
  case "binary": return e.op === "+" || e.op === "-" ? ADD : e.op === "*" || e.op === "/" || e.op === "%" ? MUL : CMP;
@@ -83,6 +83,8 @@ function render(n: AstNode): Out {
83
83
  case "subquery": return block(n);
84
84
  case "op": return op(n);
85
85
  case "follow": return follow(n);
86
+ case "groupBy": return groupBy(n);
87
+ case "key": return key(n);
86
88
  case "order": return order(n);
87
89
  case "field": case "collect": return item(n);
88
90
  case "and": case "or": case "not": case "scalar": return where(n);
@@ -115,7 +117,7 @@ function query(q: Query): Out {
115
117
  return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
116
118
  }
117
119
 
118
- type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "orderBy" | "limit" | "offset">
120
+ type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
119
121
  & Partial<Pick<Subquery, "frontier" | "depth">>;
120
122
 
121
123
  // The clauses after `from` (a query) or after the projection (a block): the
@@ -128,6 +130,8 @@ function tail(b: Omit<Body, "select" | "values">): Out[] {
128
130
  if (b.frontier) out.push(cat("frontier ", expr(b.frontier)));
129
131
  if (b.depth != null) out.push([`depth ${number(b.depth)}`]);
130
132
  if (b.follow) out.push(follow(b.follow));
133
+ if (b.groupBy) out.push(groupBy(b.groupBy));
134
+ if (b.having) out.push(cat("having ", where(b.having)));
131
135
  if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
132
136
  if (b.limit) out.push(cat("limit ", expr(b.limit)));
133
137
  if (b.offset) out.push(cat("offset ", expr(b.offset)));
@@ -179,6 +183,15 @@ function follow(f: Follow): Out {
179
183
  return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
180
184
  }
181
185
 
186
+ // Every key aliased, as every select item is (`layer: layer`).
187
+ function groupBy(g: GroupBy): Out {
188
+ return cat("group by ", join(g.keys.map(key), ", "));
189
+ }
190
+
191
+ function key(k: GroupKey): Out {
192
+ return cat(`${k.name}: `, expr(k.expr));
193
+ }
194
+
182
195
  // ---- where ------------------------------------------------------------------
183
196
 
184
197
  function where(w: Where): Out {
@@ -212,6 +225,8 @@ function expr(e: Expr): Out {
212
225
  const args = cat("(", join(e.args.map(expr), ", "), ")");
213
226
  return e.recv === null ? cat(e.name, args) : cat(receiver(e.recv), `.${e.name}`, args);
214
227
  }
228
+ // An aggregate prints as the call it is written as (`count()`, `sum(distinct x)`).
229
+ case "aggregate": return cat(e.name, "(", e.distinct ? "distinct " : "", e.arg ? expr(e.arg) : "", ")");
215
230
  case "unary": return cat(e.op, operand(e.expr, UNARY));
216
231
  // Postfix, tightest: anything below postfix level is grouped (`(a + 1)!`).
217
232
  case "required": return cat(operand(e.expr, POSTFIX), "!");