@omgbase/oqx 0.21.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 (65) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +79 -2
  3. package/dist/ast.d.ts +48 -3
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +17 -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 +3 -0
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +153 -11
  14. package/dist/engine.js.map +1 -1
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/lexer.d.ts.map +1 -1
  20. package/dist/lexer.js +13 -3
  21. package/dist/lexer.js.map +1 -1
  22. package/dist/optimize/analysis.d.ts.map +1 -1
  23. package/dist/optimize/analysis.js +19 -2
  24. package/dist/optimize/analysis.js.map +1 -1
  25. package/dist/optimize/ir.d.ts +5 -1
  26. package/dist/optimize/ir.d.ts.map +1 -1
  27. package/dist/optimize/ir.js +2 -0
  28. package/dist/optimize/ir.js.map +1 -1
  29. package/dist/optimize/rules.d.ts.map +1 -1
  30. package/dist/optimize/rules.js +4 -3
  31. package/dist/optimize/rules.js.map +1 -1
  32. package/dist/parser.d.ts.map +1 -1
  33. package/dist/parser.js +406 -21
  34. package/dist/parser.js.map +1 -1
  35. package/dist/print.d.ts.map +1 -1
  36. package/dist/print.js +17 -0
  37. package/dist/print.js.map +1 -1
  38. package/dist/references.d.ts +28 -0
  39. package/dist/references.d.ts.map +1 -0
  40. package/dist/references.js +287 -0
  41. package/dist/references.js.map +1 -0
  42. package/dist/resolve.d.ts.map +1 -1
  43. package/dist/resolve.js +20 -1
  44. package/dist/resolve.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/dist/walk.d.ts +1 -1
  48. package/dist/walk.d.ts.map +1 -1
  49. package/dist/walk.js +10 -0
  50. package/dist/walk.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/ast.ts +64 -4
  53. package/src/build.ts +11 -4
  54. package/src/engine.ts +154 -11
  55. package/src/index.ts +3 -1
  56. package/src/lexer.ts +10 -4
  57. package/src/optimize/analysis.ts +17 -2
  58. package/src/optimize/ir.ts +7 -1
  59. package/src/optimize/rules.ts +4 -3
  60. package/src/parser.ts +328 -21
  61. package/src/print.ts +19 -3
  62. package/src/references.ts +293 -0
  63. package/src/resolve.ts +22 -2
  64. package/src/version.ts +1 -1
  65. package/src/walk.ts +12 -1
package/dist/parser.js CHANGED
@@ -79,6 +79,7 @@
79
79
  // misreads.
80
80
  import { lexTemplate, lexString } from "./lexer.js";
81
81
  import { OqxError } from "./errors.js";
82
+ import { AGGREGATE_NAMES, isAggregateName } from "./ast.js";
82
83
  import { resolveSelect, resolveWhere } from "./resolve.js";
83
84
  import { printTemplate } from "./print.js";
84
85
  const CONSUMERS = new Set(["collect", "exists", "none", "count", "first", "single"]);
@@ -86,8 +87,10 @@ const CONSUMERS = new Set(["collect", "exists", "none", "count", "first", "singl
86
87
  // `frontier` is a clause of every body that has a `follow` (a destination
87
88
  // block's marks the occurrences it reaches, any other body's its seeds — 0.20);
88
89
  // `depth` is legal only in a follow destination block, and the order sentence
89
- // names it only there.
90
- const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "order by", "limit", "offset"];
90
+ // names it only there. `group by` and `having` (0.24) sit after `follow`: the
91
+ // rows a body yields — the walk's occurrences, when it has one — are what gets
92
+ // grouped, and the bound and the ordering then apply to the groups.
93
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "group by", "having", "order by", "limit", "offset"];
91
94
  const DESTINATION_ONLY = new Set(["depth"]);
92
95
  // The clauses a body may hold, in order.
93
96
  function clausesOf(ctx) {
@@ -98,10 +101,16 @@ function clausesOf(ctx) {
98
101
  const CLAUSE_WORDS = new Set([
99
102
  "collect", "exists", "none", "count", "first", "single",
100
103
  "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
101
- "limit", "offset",
104
+ "limit", "offset", "group", "having",
102
105
  ]);
106
+ // The names a group scope resolves besides its keys (SEMANTICS §24): the
107
+ // group's rows, the key record itself, and its identity.
108
+ const GROUP_SCOPE_NAMES = new Set(["rows", "$it", "$key"]);
103
109
  const RELOPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
104
- const CMP_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
110
+ // The comparison level (GRAMMAR §4): the six relational operators and, since
111
+ // 0.22, the four regex operators — sugar over `matches`, non-associative like
112
+ // the rest of the level.
113
+ const CMP_OPS = new Set(["==", "!=", "<", "<=", ">", ">=", "~", "~*", "!~", "!~*"]);
105
114
  const ADD_OPS = new Set(["+", "-"]);
106
115
  const MUL_OPS = new Set(["*", "/", "%"]);
107
116
  // `true`/`false`/`null` are literals in every position, so they can never name a
@@ -232,6 +241,8 @@ class Parser {
232
241
  consumer: "collect",
233
242
  follow: body.follow,
234
243
  frontier: body.frontier,
244
+ groupBy: body.groupBy,
245
+ having: body.having,
235
246
  distinct: body.distinct,
236
247
  values: body.values,
237
248
  limit: body.limit,
@@ -243,8 +254,8 @@ class Parser {
243
254
  queryFromOp(e, start) {
244
255
  return {
245
256
  kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
246
- orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
247
- limit: e.sub.limit, offset: e.sub.offset,
257
+ orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, groupBy: e.sub.groupBy, having: e.sub.having,
258
+ distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
248
259
  };
249
260
  }
250
261
  tokDesc() {
@@ -264,6 +275,8 @@ class Parser {
264
275
  let follow = null;
265
276
  let frontier = null;
266
277
  let depth = null;
278
+ let groupBy = null;
279
+ let having = null;
267
280
  let distinct = false;
268
281
  let values = false;
269
282
  let limit = null;
@@ -332,6 +345,20 @@ class Parser {
332
345
  follow = this.atDepth(rowDepth, () => this.parseFollow());
333
346
  continue;
334
347
  }
348
+ // `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
349
+ if (this.atGroupBy()) {
350
+ enter("group by");
351
+ groupBy = this.atDepth(rowDepth, () => this.parseGroupBy());
352
+ continue;
353
+ }
354
+ // `having P` (0.24): a predicate over each GROUP, read at the row depth
355
+ // (the group scope sits where the rows did).
356
+ if (this.atHaving()) {
357
+ enter("having");
358
+ this.next();
359
+ having = this.atDepth(rowDepth, () => this.parseWhere());
360
+ continue;
361
+ }
335
362
  if (this.atOrderBy()) {
336
363
  enter("order by");
337
364
  this.next();
@@ -376,15 +403,269 @@ class Parser {
376
403
  // Validate the alias references of this body (cycles, a block alias inside a
377
404
  // `where` expression, a `select` item naming an item to its right); the
378
405
  // surface form is kept. `where` first: its cycle report follows its own path.
379
- if (where)
380
- resolveWhere(select, where, (msg) => this.fail(msg));
381
- resolveSelect(select, (msg) => this.fail(msg));
406
+ // A grouped body without a projection yields its keys and a count (0.24);
407
+ // the items are synthesized before the alias validation sees the select.
408
+ if (groupBy && select.length === 0)
409
+ select = defaultGroupProjection(groupBy, (msg) => this.failAt(groupBy.span[0], msg));
410
+ const resolvedWhere = where ? resolveWhere(select, where, (msg) => this.fail(msg)) : null;
411
+ const resolvedHaving = having ? resolveWhere(select, having, (msg) => this.fail(msg)) : null;
412
+ // The resolved items: an aggregate reached through an alias (`n: count(),
413
+ // xs: collect(n)`) is judged where it will be evaluated.
414
+ const resolvedSelect = resolveSelect(select, (msg) => this.fail(msg));
382
415
  // Outside a destination block `frontier` marks the seeds of this body's walk,
383
416
  // so it needs one.
384
417
  if (frontier && !follow && !ctx.destination) {
385
418
  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");
386
419
  }
387
- return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
420
+ // Grouping (0.24): `having` needs a `group by`; `distinct` is redundant with
421
+ // one; aggregates may stand only in the post-group clauses; and every bare
422
+ // name read at the group scope must be a key, an alias, or the group's rows.
423
+ if (having && !groupBy) {
424
+ this.failAt(having.span[0], "`having` needs a `group by` — `having <pred>` filters the groups a body yields; a per-row filter is `where`");
425
+ }
426
+ if (groupBy) {
427
+ if (distinct)
428
+ this.failAt(groupBy.span[0], DISTINCT_WITH_GROUP);
429
+ this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy });
430
+ }
431
+ else {
432
+ this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy: null });
433
+ }
434
+ return { froms, where, select, orderBy, follow, frontier, depth, groupBy, having, distinct, values, limit, offset };
435
+ }
436
+ // ---- grouping (0.24) ------------------------------------------------------
437
+ // `group by` is a clause only as the two-word sequence (a field named `group`
438
+ // stays a name: `select group from r`).
439
+ atGroupBy() {
440
+ const t = this.peek();
441
+ const nx = this.peekAt(1);
442
+ return t.type === "ident" && t.value === "group" && !!nx && nx.type === "ident" && nx.value === "by";
443
+ }
444
+ // `having` is a clause when a predicate can follow it — a value start, or an
445
+ // aggregate call whose name is a consumer word (`having count() > 1`:
446
+ // `count` alone cannot begin a value, `count(` can); otherwise a field name.
447
+ atHaving() {
448
+ if (!this.at("ident", "having"))
449
+ return false;
450
+ const nx = this.peekAt(1);
451
+ return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
452
+ }
453
+ parseGroupBy() {
454
+ const start = this.start();
455
+ this.next();
456
+ this.next(); // `group` `by`
457
+ const keys = [this.parseGroupKey()];
458
+ while (this.at("comma")) {
459
+ this.next();
460
+ keys.push(this.parseGroupKey());
461
+ }
462
+ const seen = new Set();
463
+ for (const k of keys) {
464
+ if (seen.has(k.name))
465
+ this.failAt(k.span[0], `duplicate group key '${k.name}' — each key needs its own name (alias one: \`other: expr\`)`);
466
+ seen.add(k.name);
467
+ }
468
+ return { kind: "groupBy", span: this.sp(start), keys };
469
+ }
470
+ // One key, shaped like a select item: `name: expr`, or an unaliased navigation
471
+ // keyed by its last segment; any other expression needs the alias, because the
472
+ // alias is how the post-group clauses refer to the key.
473
+ parseGroupKey() {
474
+ const start = this.start();
475
+ if (this.at("ident") && this.peekAt(1)?.type === "colon") {
476
+ const name = this.next().value;
477
+ this.next(); // ':'
478
+ const expr = this.parseValueExpr();
479
+ return { kind: "key", span: this.sp(start), name, expr };
480
+ }
481
+ if (!this.canStartValue())
482
+ this.fail("expected a group key after `group by` (`group by <name>` or `group by <alias>: <expr>`)");
483
+ const expr = this.parseValueExpr();
484
+ const name = navKey(expr);
485
+ if (name === null) {
486
+ 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`);
487
+ }
488
+ return { kind: "key", span: this.sp(start), name, expr };
489
+ }
490
+ // `name(` where `name` is an aggregate: `count()`, `sum(e)`, `collect(distinct
491
+ // e)`. The cursor is at the `(`. Arity and the `distinct` modifier are checked
492
+ // here; WHERE the aggregate may stand is the body's business (`checkGroupedBody`).
493
+ parseAggregate(name, start) {
494
+ this.next(); // '('
495
+ let distinct = false;
496
+ if (this.at("ident", "distinct") && this.startsValue(this.peekAt(1))) {
497
+ this.next();
498
+ distinct = true;
499
+ }
500
+ let arg = null;
501
+ if (!this.at("rparen")) {
502
+ arg = this.parseValueExpr();
503
+ if (this.at("comma"))
504
+ this.fail(`\`${name}(…)\` takes one argument — an aggregate reduces one expression over the group's rows`);
505
+ }
506
+ if (!this.at("rparen"))
507
+ this.fail(`expected ')' to close \`${name}(…)\``);
508
+ this.next();
509
+ if (arg === null && name !== "count")
510
+ this.fail(`\`${name}()\` needs an argument (\`${name}(<expr>)\`) — only \`count()\` counts the group's rows without one`);
511
+ if (distinct && (name === "any" || name === "all")) {
512
+ this.fail(`\`${name}(distinct …)\`: \`distinct\` applies to a value aggregate (count, sum, min, max, avg, collect, first, last), not to a predicate aggregate`);
513
+ }
514
+ return { kind: "aggregate", span: [start, this.end()], name, arg, distinct };
515
+ }
516
+ // A free call: an aggregate node for an aggregate name (reserved since 0.24),
517
+ // an ordinary `call` otherwise. The cursor is at the `(`.
518
+ parseFreeCall(name, start) {
519
+ if (isAggregateName(name))
520
+ return this.parseAggregate(name, start);
521
+ const args = this.parseArgs();
522
+ return { kind: "call", span: this.sp(start), recv: null, name, args };
523
+ }
524
+ // The clauses an aggregate may NOT stand in — every clause read before the
525
+ // rows are grouped (`from`, `where`, `frontier`, the keys, the bound) — and,
526
+ // in a body without `group by`, every clause. The `where` is the alias-resolved
527
+ // one, so an aggregate reached through a select alias is caught too.
528
+ rejectAggregates(b) {
529
+ const found = [];
530
+ const collect = (exprs, clause) => {
531
+ const out = [];
532
+ for (const e of exprs)
533
+ if (e)
534
+ aggregatesIn(e, out);
535
+ for (const a of out)
536
+ found.push([a, clause]);
537
+ };
538
+ const where = [];
539
+ if (b.where)
540
+ whereAggregates(b.where, where);
541
+ for (const a of where)
542
+ found.push([a, "`where`"]);
543
+ collect(b.froms, "`from`");
544
+ collect([b.frontier], "`frontier`");
545
+ collect([b.limit], "`limit`");
546
+ collect([b.offset], "`offset`");
547
+ if (b.groupBy)
548
+ collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
549
+ if (!b.groupBy) {
550
+ collect(b.resolvedSelect.filter((it) => it.kind === "field").map((it) => it.expr), "a select item");
551
+ if (b.orderBy)
552
+ collect(b.orderBy.map((o) => o.expr), "`order by`");
553
+ }
554
+ const hit = found[0];
555
+ if (!hit)
556
+ return;
557
+ const [agg, clause] = hit;
558
+ if (!b.groupBy) {
559
+ 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`);
560
+ }
561
+ 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\``);
562
+ }
563
+ // A grouped body: aggregates only after the grouping (and never nested), and
564
+ // every bare name at the group scope a key, an alias, or the group's rows.
565
+ checkGroupedBody(b) {
566
+ this.rejectAggregates(b);
567
+ // An aggregate's argument is read on the group's rows, where there is no
568
+ // group to aggregate: `sum(count())` is an error, not a double reduction.
569
+ const post = [];
570
+ for (const it of b.resolvedSelect)
571
+ if (it.kind === "field")
572
+ aggregatesIn(it.expr, post);
573
+ if (b.resolvedHaving)
574
+ whereAggregates(b.resolvedHaving, post);
575
+ if (b.orderBy)
576
+ for (const o of b.orderBy)
577
+ aggregatesIn(o.expr, post);
578
+ const nested = nestedAggregate(post);
579
+ if (nested)
580
+ 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`);
581
+ const keys = new Set(b.groupBy.keys.map((k) => k.name));
582
+ const valid = (aliases) => new Set([...keys, ...GROUP_SCOPE_NAMES, ...aliases]);
583
+ const aliases = [];
584
+ for (const it of b.select) {
585
+ const names = valid(aliases);
586
+ if (it.kind === "field")
587
+ this.checkGroupExpr(it.expr, names, "a select item");
588
+ else
589
+ this.checkGroupExpr(it.op.receiver, names, "a select item");
590
+ const own = it.kind === "collect" ? (it.name === "" ? null : it.name) : it.lift === 0 && it.name !== "" ? it.name : null;
591
+ if (own !== null)
592
+ aliases.push(own);
593
+ }
594
+ if (b.having)
595
+ this.checkGroupWhere(b.having, valid(aliases), "`having`");
596
+ if (b.orderBy)
597
+ for (const o of b.orderBy)
598
+ this.checkGroupExpr(o.expr, valid([]), "`order by`");
599
+ }
600
+ checkGroupWhere(w, valid, where) {
601
+ switch (w.kind) {
602
+ case "and":
603
+ case "or":
604
+ w.parts.forEach((p) => this.checkGroupWhere(p, valid, where));
605
+ return;
606
+ case "not":
607
+ this.checkGroupWhere(w.expr, valid, where);
608
+ return;
609
+ case "scalar":
610
+ this.checkGroupExpr(w.expr, valid, where);
611
+ return;
612
+ case "op":
613
+ this.checkGroupExpr(w.receiver, valid, where);
614
+ return; // the block is its own scope
615
+ }
616
+ }
617
+ // Every bare name `e` reads at the group scope (an aggregate's argument reads
618
+ // the group's rows and is not checked; a nested block is its own scope, only
619
+ // its receiver is read here). `inCall` names the enclosing non-aggregate free
620
+ // call, for the "not an aggregate" hint.
621
+ checkGroupExpr(e, valid, where, inCall = null) {
622
+ switch (e.kind) {
623
+ case "ident":
624
+ if (!valid.has(e.name)) {
625
+ const hint = inCall === null ? "" : `; \`${inCall}(…)\` is not an aggregate — the aggregates are ${[...AGGREGATE_NAMES].join(", ")}`;
626
+ 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}`);
627
+ }
628
+ return;
629
+ case "lit":
630
+ case "binding":
631
+ case "outer":
632
+ case "aggregate": return;
633
+ case "member":
634
+ this.checkGroupExpr(e.recv, valid, where, inCall);
635
+ return;
636
+ case "call":
637
+ if (e.recv)
638
+ this.checkGroupExpr(e.recv, valid, where, inCall);
639
+ for (const a of e.args)
640
+ this.checkGroupExpr(a, valid, where, e.recv === null ? e.name : inCall);
641
+ return;
642
+ case "unary":
643
+ case "required":
644
+ this.checkGroupExpr(e.expr, valid, where, inCall);
645
+ return;
646
+ case "binary":
647
+ case "logical":
648
+ case "in":
649
+ this.checkGroupExpr(e.left, valid, where, inCall);
650
+ this.checkGroupExpr(e.right, valid, where, inCall);
651
+ return;
652
+ case "range":
653
+ if (e.lo)
654
+ this.checkGroupExpr(e.lo, valid, where, inCall);
655
+ if (e.hi)
656
+ this.checkGroupExpr(e.hi, valid, where, inCall);
657
+ return;
658
+ case "tuple":
659
+ e.items.forEach((x) => this.checkGroupExpr(x, valid, where, inCall));
660
+ return;
661
+ case "template":
662
+ e.parts.forEach((p) => { if (typeof p !== "string")
663
+ this.checkGroupExpr(p, valid, where, inCall); });
664
+ return;
665
+ case "op":
666
+ this.checkGroupExpr(e.receiver, valid, where, inCall);
667
+ return;
668
+ }
388
669
  }
389
670
  // The keyword-less run that opens a body. At the top level it is always the
390
671
  // projection. In a block it is a `where`-first body when the leading expression
@@ -469,6 +750,10 @@ class Parser {
469
750
  this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
470
751
  if (t.value === "frontier")
471
752
  this.fail(`unexpected 'frontier' after \`${last}\` — \`frontier\` takes an expression (\`frontier <expr>\`) and belongs to a body that has a \`follow\``);
753
+ if (t.value === "group")
754
+ this.fail(`unexpected 'group' after \`${last}\` — a grouping is written \`group by <key>[, <key>…]\` (a key is a name or \`alias: <expr>\`)`);
755
+ if (t.value === "having")
756
+ 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\``);
472
757
  }
473
758
  if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
474
759
  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\``);
@@ -637,10 +922,8 @@ class Parser {
637
922
  ? { kind: "outer", span: [start, head.end], levels, name: head.value }
638
923
  : { kind: "ident", span: [start, head.end], name: head.value };
639
924
  let name = head.value;
640
- if (levels === 0 && this.at("lparen")) {
641
- const args = this.parseArgs();
642
- expr = { kind: "call", span: this.sp(start), recv: null, name: head.value, args };
643
- }
925
+ if (levels === 0 && this.at("lparen"))
926
+ expr = this.parseFreeCall(head.value, start);
644
927
  while (this.at("dot")) {
645
928
  this.next();
646
929
  if (!this.at("ident"))
@@ -716,8 +999,8 @@ class Parser {
716
999
  // `name: jobs first { }.pay`: the directive is the head of a longer value.
717
1000
  if (this.atScalarContinuation() || this.atValueDirective())
718
1001
  return this.finishItem(name, this.parseOr(op), lift, start);
719
- if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
720
- this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
1002
+ if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
1003
+ 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)`);
721
1004
  }
722
1005
  return this.finishItem(name, op, lift, start);
723
1006
  }
@@ -1046,6 +1329,10 @@ class Parser {
1046
1329
  if (!this.at("rbrace"))
1047
1330
  this.fail(`expected '}' to close the ${op} { … } block`);
1048
1331
  this.next();
1332
+ // `x collect distinct { group by k }`: the directive's distinct is as
1333
+ // redundant as the body's own (checked in parseBody).
1334
+ if (distinct && body.groupBy)
1335
+ this.failAt(body.groupBy.span[0], DISTINCT_WITH_GROUP);
1049
1336
  const sub = {
1050
1337
  kind: "subquery",
1051
1338
  span: this.sp(lbrace),
@@ -1056,6 +1343,8 @@ class Parser {
1056
1343
  follow: body.follow,
1057
1344
  frontier: body.frontier,
1058
1345
  depth: body.depth,
1346
+ groupBy: body.groupBy,
1347
+ having: body.having,
1059
1348
  values: body.values,
1060
1349
  limit: body.limit,
1061
1350
  offset: body.offset,
@@ -1127,7 +1416,7 @@ class Parser {
1127
1416
  this.next();
1128
1417
  const subSpan = [lbracket, this.end()];
1129
1418
  const sub = (parts) => ({
1130
- kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
1419
+ 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,
1131
1420
  });
1132
1421
  const bang = this.atOp("!") ? this.next() : null;
1133
1422
  if (index) {
@@ -1288,9 +1577,9 @@ class Parser {
1288
1577
  }
1289
1578
  }
1290
1579
  else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
1291
- // free function call: name(args) — a bare name read here, not `(f)(x)`
1292
- const args = this.parseArgs();
1293
- expr = { kind: "call", span: this.sp(start), recv: null, name: expr.name, args };
1580
+ // free function call: name(args) — a bare name read here, not `(f)(x)`;
1581
+ // an aggregate name makes it an `aggregate` node (0.24)
1582
+ expr = this.parseFreeCall(expr.name, start);
1294
1583
  }
1295
1584
  else if (this.at("lbracket")) {
1296
1585
  expr = this.parseBracket(expr);
@@ -1317,9 +1606,12 @@ class Parser {
1317
1606
  }
1318
1607
  return expr;
1319
1608
  }
1609
+ // The directives that have a value form: collect/first/single, and since 0.24
1610
+ // `count { … }` (the number of rows the block yields). exists/none are
1611
+ // predicates only.
1320
1612
  atValueDirective() {
1321
1613
  const op = this.atConsumerBlock();
1322
- return op === "collect" || op === "first" || op === "single";
1614
+ return op === "collect" || op === "first" || op === "single" || op === "count";
1323
1615
  }
1324
1616
  parseArgs() {
1325
1617
  this.next(); // '('
@@ -1437,6 +1729,99 @@ class Parser {
1437
1729
  return { kind: "template", span: [open.pos, this.end()], parts };
1438
1730
  }
1439
1731
  }
1732
+ // ---- grouping helpers (0.24) --------------------------------------------------
1733
+ const DISTINCT_WITH_GROUP = "`distinct` is redundant with `group by` — a grouped body already yields one row per distinct key tuple; drop `distinct`";
1734
+ // The projection of a grouped body that writes none (SEMANTICS §24): every key
1735
+ // by its name, then `count: count()`. Synthesized here so the tree shows what
1736
+ // the body projects and the engine has no grouped "row itself" to define; the
1737
+ // nodes carry the empty span (they did not come from source).
1738
+ function defaultGroupProjection(groupBy, fail) {
1739
+ if (groupBy.keys.some((k) => k.name === "count")) {
1740
+ fail("a group key named `count` needs an explicit `select` — the default projection of a grouped body is its keys plus `count: count()`");
1741
+ }
1742
+ const items = groupBy.keys.map((k) => ({ kind: "field", span: [0, 0], name: k.name, expr: { kind: "ident", span: [0, 0], name: k.name }, lift: 0 }));
1743
+ items.push({ kind: "field", span: [0, 0], name: "count", expr: { kind: "aggregate", span: [0, 0], name: "count", arg: null, distinct: false }, lift: 0 });
1744
+ return items;
1745
+ }
1746
+ // Every aggregate node in `e` read at THIS scope (a nested block's body is its
1747
+ // own scope and is not entered; a block's receiver is). An aggregate's argument
1748
+ // is entered too, so a nested aggregate is found by `nestedAggregate`.
1749
+ function aggregatesIn(e, out) {
1750
+ switch (e.kind) {
1751
+ case "aggregate":
1752
+ out.push(e);
1753
+ if (e.arg)
1754
+ aggregatesIn(e.arg, out);
1755
+ return;
1756
+ case "lit":
1757
+ case "ident":
1758
+ case "outer":
1759
+ case "binding": return;
1760
+ case "member":
1761
+ aggregatesIn(e.recv, out);
1762
+ return;
1763
+ case "call":
1764
+ if (e.recv)
1765
+ aggregatesIn(e.recv, out);
1766
+ e.args.forEach((a) => aggregatesIn(a, out));
1767
+ return;
1768
+ case "unary":
1769
+ case "required":
1770
+ aggregatesIn(e.expr, out);
1771
+ return;
1772
+ case "binary":
1773
+ case "logical":
1774
+ case "in":
1775
+ aggregatesIn(e.left, out);
1776
+ aggregatesIn(e.right, out);
1777
+ return;
1778
+ case "range":
1779
+ if (e.lo)
1780
+ aggregatesIn(e.lo, out);
1781
+ if (e.hi)
1782
+ aggregatesIn(e.hi, out);
1783
+ return;
1784
+ case "tuple":
1785
+ e.items.forEach((x) => aggregatesIn(x, out));
1786
+ return;
1787
+ case "template":
1788
+ e.parts.forEach((p) => { if (typeof p !== "string")
1789
+ aggregatesIn(p, out); });
1790
+ return;
1791
+ case "op":
1792
+ aggregatesIn(e.receiver, out);
1793
+ return;
1794
+ }
1795
+ }
1796
+ function whereAggregates(w, out) {
1797
+ switch (w.kind) {
1798
+ case "and":
1799
+ case "or":
1800
+ w.parts.forEach((p) => whereAggregates(p, out));
1801
+ return;
1802
+ case "not":
1803
+ whereAggregates(w.expr, out);
1804
+ return;
1805
+ case "scalar":
1806
+ aggregatesIn(w.expr, out);
1807
+ return;
1808
+ case "op":
1809
+ aggregatesIn(w.receiver, out);
1810
+ return;
1811
+ }
1812
+ }
1813
+ // The first aggregate whose argument holds another (`sum(count())`), if any.
1814
+ function nestedAggregate(found) {
1815
+ for (const a of found) {
1816
+ if (a.kind !== "aggregate" || !a.arg)
1817
+ continue;
1818
+ const inner = [];
1819
+ aggregatesIn(a.arg, inner);
1820
+ if (inner.length > 0)
1821
+ return a;
1822
+ }
1823
+ return null;
1824
+ }
1440
1825
  // The default key of an unaliased projection item: the last segment of a bare /
1441
1826
  // dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
1442
1827
  function navKey(e) {