@omgbase/oqx 0.23.0 → 0.25.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 +12 -0
  2. package/README.md +71 -2
  3. package/dist/ast.d.ts +53 -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 +9 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +11 -2
  10. package/dist/build.js.map +1 -1
  11. package/dist/engine.d.ts +4 -0
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +188 -13
  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 +24 -1
  21. package/dist/optimize/analysis.js.map +1 -1
  22. package/dist/optimize/ir.d.ts +8 -1
  23. package/dist/optimize/ir.d.ts.map +1 -1
  24. package/dist/optimize/ir.js +3 -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 +5 -3
  28. package/dist/optimize/rules.js.map +1 -1
  29. package/dist/parser.d.ts.map +1 -1
  30. package/dist/parser.js +467 -22
  31. package/dist/parser.js.map +1 -1
  32. package/dist/print.d.ts.map +1 -1
  33. package/dist/print.js +25 -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 +104 -4
  38. package/dist/references.js.map +1 -1
  39. package/dist/resolve.d.ts.map +1 -1
  40. package/dist/resolve.js +27 -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 +15 -0
  47. package/dist/walk.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/ast.ts +63 -2
  50. package/src/build.ts +13 -4
  51. package/src/engine.ts +186 -13
  52. package/src/index.ts +1 -1
  53. package/src/optimize/analysis.ts +18 -1
  54. package/src/optimize/ir.ts +11 -1
  55. package/src/optimize/rules.ts +5 -3
  56. package/src/parser.ts +387 -22
  57. package/src/print.ts +27 -3
  58. package/src/references.ts +95 -8
  59. package/src/resolve.ts +29 -2
  60. package/src/version.ts +1 -1
  61. package/src/walk.ts +18 -1
package/dist/parser.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // consumer block — each clause appears at most once, in exactly this order:
14
14
  //
15
15
  // select <projection> from <source> where <predicate> follow <dest>, … {…}
16
- // order by … limit N offset N
16
+ // flatten <expr> [{…}] [as <name>] … group by … having … order by … limit N offset N
17
17
  //
18
18
  // A `follow` destination is a relation of the current row (a receiver, never
19
19
  // an outer reference) or a destination block — a receiver (which MAY be an
@@ -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,13 @@ 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. `flatten`
93
+ // (0.25) sits between them: it fans the rows a body yields — the occurrences,
94
+ // after a walk — out per element, and is the one clause that may repeat (each
95
+ // `flatten` fans out the rows the previous produced, SEMANTICS §25).
96
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "flatten", "group by", "having", "order by", "limit", "offset"];
91
97
  const DESTINATION_ONLY = new Set(["depth"]);
92
98
  // The clauses a body may hold, in order.
93
99
  function clausesOf(ctx) {
@@ -98,8 +104,11 @@ function clausesOf(ctx) {
98
104
  const CLAUSE_WORDS = new Set([
99
105
  "collect", "exists", "none", "count", "first", "single",
100
106
  "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
101
- "limit", "offset",
107
+ "limit", "offset", "group", "having", "flatten", "as",
102
108
  ]);
109
+ // The names a group scope resolves besides its keys (SEMANTICS §24): the
110
+ // group's rows, the key record itself, and its identity.
111
+ const GROUP_SCOPE_NAMES = new Set(["rows", "$it", "$key"]);
103
112
  const RELOPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
104
113
  // The comparison level (GRAMMAR §4): the six relational operators and, since
105
114
  // 0.22, the four regex operators — sugar over `matches`, non-associative like
@@ -235,6 +244,9 @@ class Parser {
235
244
  consumer: "collect",
236
245
  follow: body.follow,
237
246
  frontier: body.frontier,
247
+ flatten: body.flatten,
248
+ groupBy: body.groupBy,
249
+ having: body.having,
238
250
  distinct: body.distinct,
239
251
  values: body.values,
240
252
  limit: body.limit,
@@ -246,8 +258,8 @@ class Parser {
246
258
  queryFromOp(e, start) {
247
259
  return {
248
260
  kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
249
- orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
250
- limit: e.sub.limit, offset: e.sub.offset,
261
+ orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, flatten: e.sub.flatten, groupBy: e.sub.groupBy, having: e.sub.having,
262
+ distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
251
263
  };
252
264
  }
253
265
  tokDesc() {
@@ -267,6 +279,9 @@ class Parser {
267
279
  let follow = null;
268
280
  let frontier = null;
269
281
  let depth = null;
282
+ const flatten = [];
283
+ let groupBy = null;
284
+ let having = null;
270
285
  let distinct = false;
271
286
  let values = false;
272
287
  let limit = null;
@@ -278,7 +293,9 @@ class Parser {
278
293
  const rowDepth = ctx.top ? 1 : this.depth;
279
294
  const enter = (clause) => {
280
295
  const idx = CLAUSE_ORDER.indexOf(clause);
281
- if (idx === stage)
296
+ // `flatten` is the one clause that may repeat (0.25): each fans out the
297
+ // rows the previous produced.
298
+ if (idx === stage && clause !== "flatten")
282
299
  this.fail(`duplicate \`${clause}\` clause`);
283
300
  if (idx < stage) {
284
301
  this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
@@ -335,6 +352,28 @@ class Parser {
335
352
  follow = this.atDepth(rowDepth, () => this.parseFollow());
336
353
  continue;
337
354
  }
355
+ // `flatten <expr> [{ … }] [as <name>]` (0.25): the value is read on each
356
+ // row, like `where`; a brace after a receiver is the receiver-block sugar
357
+ // (the `collect` op), so the block needs no slot of its own.
358
+ if (this.atFlatten()) {
359
+ enter("flatten");
360
+ flatten.push(this.atDepth(rowDepth, () => this.parseFlatten(flatten)));
361
+ continue;
362
+ }
363
+ // `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
364
+ if (this.atGroupBy()) {
365
+ enter("group by");
366
+ groupBy = this.atDepth(rowDepth, () => this.parseGroupBy());
367
+ continue;
368
+ }
369
+ // `having P` (0.24): a predicate over each GROUP, read at the row depth
370
+ // (the group scope sits where the rows did).
371
+ if (this.atHaving()) {
372
+ enter("having");
373
+ this.next();
374
+ having = this.atDepth(rowDepth, () => this.parseWhere());
375
+ continue;
376
+ }
338
377
  if (this.atOrderBy()) {
339
378
  enter("order by");
340
379
  this.next();
@@ -379,15 +418,314 @@ class Parser {
379
418
  // Validate the alias references of this body (cycles, a block alias inside a
380
419
  // `where` expression, a `select` item naming an item to its right); the
381
420
  // surface form is kept. `where` first: its cycle report follows its own path.
382
- if (where)
383
- resolveWhere(select, where, (msg) => this.fail(msg));
384
- resolveSelect(select, (msg) => this.fail(msg));
421
+ // A grouped body without a projection yields its keys and a count (0.24);
422
+ // the items are synthesized before the alias validation sees the select.
423
+ if (groupBy && select.length === 0)
424
+ select = defaultGroupProjection(groupBy, (msg) => this.failAt(groupBy.span[0], msg));
425
+ const resolvedWhere = where ? resolveWhere(select, where, (msg) => this.fail(msg)) : null;
426
+ const resolvedHaving = having ? resolveWhere(select, having, (msg) => this.fail(msg)) : null;
427
+ // The resolved items: an aggregate reached through an alias (`n: count(),
428
+ // xs: collect(n)`) is judged where it will be evaluated.
429
+ const resolvedSelect = resolveSelect(select, (msg) => this.fail(msg));
385
430
  // Outside a destination block `frontier` marks the seeds of this body's walk,
386
431
  // so it needs one.
387
432
  if (frontier && !follow && !ctx.destination) {
388
433
  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");
389
434
  }
390
- return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
435
+ // Grouping (0.24): `having` needs a `group by`; `distinct` is redundant with
436
+ // one; aggregates may stand only in the post-group clauses; and every bare
437
+ // name read at the group scope must be a key, an alias, or the group's rows.
438
+ if (having && !groupBy) {
439
+ this.failAt(having.span[0], "`having` needs a `group by` — `having <pred>` filters the groups a body yields; a per-row filter is `where`");
440
+ }
441
+ if (groupBy) {
442
+ if (distinct)
443
+ this.failAt(groupBy.span[0], DISTINCT_WITH_GROUP);
444
+ this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy });
445
+ }
446
+ else {
447
+ this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy: null });
448
+ }
449
+ return { froms, where, select, orderBy, follow, frontier, depth, flatten, groupBy, having, distinct, values, limit, offset };
450
+ }
451
+ // ---- flatten (0.25) -------------------------------------------------------
452
+ // `flatten` is a clause when a value can follow the word; otherwise it is a
453
+ // field name (`select flatten from r`).
454
+ atFlatten() {
455
+ if (!this.at("ident", "flatten"))
456
+ return false;
457
+ const nx = this.peekAt(1);
458
+ // A value start — or an aggregate call whose name is a consumer word
459
+ // (`flatten collect(x)`), admitted so the aggregate check can name it.
460
+ return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
461
+ }
462
+ // `flatten <expr> [as <name>]`. The expression is any value — a block after a
463
+ // receiver is read by the postfix grammar as the implicit `collect` op (0.20),
464
+ // which is exactly the block over the coerced collection SEMANTICS §25 wants.
465
+ // The name defaults to a plain navigation's last segment (as a select item's
466
+ // or a group key's does); anything else needs `as`. Names are unique within
467
+ // the body, never a `$`-name (the language's), a keyword or a literal word.
468
+ parseFlatten(earlier) {
469
+ const start = this.start();
470
+ this.next(); // `flatten`
471
+ const expr = this.parseValueExpr();
472
+ let name;
473
+ if (this.at("ident", "as")) {
474
+ this.next();
475
+ const t = this.peek();
476
+ if (t.type !== "ident")
477
+ this.fail("expected a name after `as` (`flatten <expr> as <name>`)");
478
+ if (LITERAL_WORDS.has(t.value))
479
+ this.fail(`\`${t.value}\` is a literal and cannot name a flattened value (\`flatten <expr> as <name>\`)`);
480
+ if (t.value.startsWith("$"))
481
+ this.fail(`a flattened value cannot be bound to a \`$\`-name (\`${t.value}\`) — \`$\` belongs to the language; choose another name`);
482
+ name = this.next().value;
483
+ }
484
+ else {
485
+ const key = navKey(expr);
486
+ if (key === null) {
487
+ this.failAt(start, `\`flatten ${describe(expr)}\` needs a name (\`flatten ${describe(expr)} as <name>\`) — only a plain navigation names itself by its last segment`);
488
+ }
489
+ name = key;
490
+ }
491
+ if (earlier.some((f) => f.name === name))
492
+ this.failAt(start, `duplicate flatten name '${name}' — each flatten binds its own name (\`flatten <expr> as <other>\`)`);
493
+ return { kind: "flatten", span: this.sp(start), expr, name };
494
+ }
495
+ // ---- grouping (0.24) ------------------------------------------------------
496
+ // `group by` is a clause only as the two-word sequence (a field named `group`
497
+ // stays a name: `select group from r`).
498
+ atGroupBy() {
499
+ const t = this.peek();
500
+ const nx = this.peekAt(1);
501
+ return t.type === "ident" && t.value === "group" && !!nx && nx.type === "ident" && nx.value === "by";
502
+ }
503
+ // `having` is a clause when a predicate can follow it — a value start, or an
504
+ // aggregate call whose name is a consumer word (`having count() > 1`:
505
+ // `count` alone cannot begin a value, `count(` can); otherwise a field name.
506
+ atHaving() {
507
+ if (!this.at("ident", "having"))
508
+ return false;
509
+ const nx = this.peekAt(1);
510
+ return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
511
+ }
512
+ parseGroupBy() {
513
+ const start = this.start();
514
+ this.next();
515
+ this.next(); // `group` `by`
516
+ const keys = [this.parseGroupKey()];
517
+ while (this.at("comma")) {
518
+ this.next();
519
+ keys.push(this.parseGroupKey());
520
+ }
521
+ const seen = new Set();
522
+ for (const k of keys) {
523
+ if (seen.has(k.name))
524
+ this.failAt(k.span[0], `duplicate group key '${k.name}' — each key needs its own name (alias one: \`other: expr\`)`);
525
+ seen.add(k.name);
526
+ }
527
+ return { kind: "groupBy", span: this.sp(start), keys };
528
+ }
529
+ // One key, shaped like a select item: `name: expr`, or an unaliased navigation
530
+ // keyed by its last segment; any other expression needs the alias, because the
531
+ // alias is how the post-group clauses refer to the key.
532
+ parseGroupKey() {
533
+ const start = this.start();
534
+ if (this.at("ident") && this.peekAt(1)?.type === "colon") {
535
+ const name = this.next().value;
536
+ this.next(); // ':'
537
+ const expr = this.parseValueExpr();
538
+ return { kind: "key", span: this.sp(start), name, expr };
539
+ }
540
+ if (!this.canStartValue())
541
+ this.fail("expected a group key after `group by` (`group by <name>` or `group by <alias>: <expr>`)");
542
+ const expr = this.parseValueExpr();
543
+ const name = navKey(expr);
544
+ if (name === null) {
545
+ 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`);
546
+ }
547
+ return { kind: "key", span: this.sp(start), name, expr };
548
+ }
549
+ // `name(` where `name` is an aggregate: `count()`, `sum(e)`, `collect(distinct
550
+ // e)`. The cursor is at the `(`. Arity and the `distinct` modifier are checked
551
+ // here; WHERE the aggregate may stand is the body's business (`checkGroupedBody`).
552
+ parseAggregate(name, start) {
553
+ this.next(); // '('
554
+ let distinct = false;
555
+ if (this.at("ident", "distinct") && this.startsValue(this.peekAt(1))) {
556
+ this.next();
557
+ distinct = true;
558
+ }
559
+ let arg = null;
560
+ if (!this.at("rparen")) {
561
+ arg = this.parseValueExpr();
562
+ if (this.at("comma"))
563
+ this.fail(`\`${name}(…)\` takes one argument — an aggregate reduces one expression over the group's rows`);
564
+ }
565
+ if (!this.at("rparen"))
566
+ this.fail(`expected ')' to close \`${name}(…)\``);
567
+ this.next();
568
+ if (arg === null && name !== "count")
569
+ this.fail(`\`${name}()\` needs an argument (\`${name}(<expr>)\`) — only \`count()\` counts the group's rows without one`);
570
+ if (distinct && (name === "any" || name === "all")) {
571
+ this.fail(`\`${name}(distinct …)\`: \`distinct\` applies to a value aggregate (count, sum, min, max, avg, collect, first, last), not to a predicate aggregate`);
572
+ }
573
+ return { kind: "aggregate", span: [start, this.end()], name, arg, distinct };
574
+ }
575
+ // A free call: an aggregate node for an aggregate name (reserved since 0.24),
576
+ // an ordinary `call` otherwise. The cursor is at the `(`.
577
+ parseFreeCall(name, start) {
578
+ if (isAggregateName(name))
579
+ return this.parseAggregate(name, start);
580
+ const args = this.parseArgs();
581
+ return { kind: "call", span: this.sp(start), recv: null, name, args };
582
+ }
583
+ // The clauses an aggregate may NOT stand in — every clause read before the
584
+ // rows are grouped (`from`, `where`, `frontier`, the keys, the bound) — and,
585
+ // in a body without `group by`, every clause. The `where` is the alias-resolved
586
+ // one, so an aggregate reached through a select alias is caught too.
587
+ rejectAggregates(b) {
588
+ const found = [];
589
+ const collect = (exprs, clause) => {
590
+ const out = [];
591
+ for (const e of exprs)
592
+ if (e)
593
+ aggregatesIn(e, out);
594
+ for (const a of out)
595
+ found.push([a, clause]);
596
+ };
597
+ const where = [];
598
+ if (b.where)
599
+ whereAggregates(b.where, where);
600
+ for (const a of where)
601
+ found.push([a, "`where`"]);
602
+ collect(b.froms, "`from`");
603
+ collect([b.frontier], "`frontier`");
604
+ collect(b.flatten.map((f) => f.expr), "`flatten`");
605
+ collect([b.limit], "`limit`");
606
+ collect([b.offset], "`offset`");
607
+ if (b.groupBy)
608
+ collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
609
+ if (!b.groupBy) {
610
+ collect(b.resolvedSelect.filter((it) => it.kind === "field").map((it) => it.expr), "a select item");
611
+ if (b.orderBy)
612
+ collect(b.orderBy.map((o) => o.expr), "`order by`");
613
+ }
614
+ const hit = found[0];
615
+ if (!hit)
616
+ return;
617
+ const [agg, clause] = hit;
618
+ if (!b.groupBy) {
619
+ 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`);
620
+ }
621
+ 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\``);
622
+ }
623
+ // A grouped body: aggregates only after the grouping (and never nested), and
624
+ // every bare name at the group scope a key, an alias, or the group's rows.
625
+ checkGroupedBody(b) {
626
+ this.rejectAggregates(b);
627
+ // An aggregate's argument is read on the group's rows, where there is no
628
+ // group to aggregate: `sum(count())` is an error, not a double reduction.
629
+ const post = [];
630
+ for (const it of b.resolvedSelect)
631
+ if (it.kind === "field")
632
+ aggregatesIn(it.expr, post);
633
+ if (b.resolvedHaving)
634
+ whereAggregates(b.resolvedHaving, post);
635
+ if (b.orderBy)
636
+ for (const o of b.orderBy)
637
+ aggregatesIn(o.expr, post);
638
+ const nested = nestedAggregate(post);
639
+ if (nested)
640
+ 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`);
641
+ const keys = new Set(b.groupBy.keys.map((k) => k.name));
642
+ const valid = (aliases) => new Set([...keys, ...GROUP_SCOPE_NAMES, ...aliases]);
643
+ const aliases = [];
644
+ for (const it of b.select) {
645
+ const names = valid(aliases);
646
+ if (it.kind === "field")
647
+ this.checkGroupExpr(it.expr, names, "a select item");
648
+ else
649
+ this.checkGroupExpr(it.op.receiver, names, "a select item");
650
+ const own = it.kind === "collect" ? (it.name === "" ? null : it.name) : it.lift === 0 && it.name !== "" ? it.name : null;
651
+ if (own !== null)
652
+ aliases.push(own);
653
+ }
654
+ if (b.having)
655
+ this.checkGroupWhere(b.having, valid(aliases), "`having`");
656
+ if (b.orderBy)
657
+ for (const o of b.orderBy)
658
+ this.checkGroupExpr(o.expr, valid([]), "`order by`");
659
+ }
660
+ checkGroupWhere(w, valid, where) {
661
+ switch (w.kind) {
662
+ case "and":
663
+ case "or":
664
+ w.parts.forEach((p) => this.checkGroupWhere(p, valid, where));
665
+ return;
666
+ case "not":
667
+ this.checkGroupWhere(w.expr, valid, where);
668
+ return;
669
+ case "scalar":
670
+ this.checkGroupExpr(w.expr, valid, where);
671
+ return;
672
+ case "op":
673
+ this.checkGroupExpr(w.receiver, valid, where);
674
+ return; // the block is its own scope
675
+ }
676
+ }
677
+ // Every bare name `e` reads at the group scope (an aggregate's argument reads
678
+ // the group's rows and is not checked; a nested block is its own scope, only
679
+ // its receiver is read here). `inCall` names the enclosing non-aggregate free
680
+ // call, for the "not an aggregate" hint.
681
+ checkGroupExpr(e, valid, where, inCall = null) {
682
+ switch (e.kind) {
683
+ case "ident":
684
+ if (!valid.has(e.name)) {
685
+ const hint = inCall === null ? "" : `; \`${inCall}(…)\` is not an aggregate — the aggregates are ${[...AGGREGATE_NAMES].join(", ")}`;
686
+ 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}`);
687
+ }
688
+ return;
689
+ case "lit":
690
+ case "binding":
691
+ case "outer":
692
+ case "aggregate": return;
693
+ case "member":
694
+ this.checkGroupExpr(e.recv, valid, where, inCall);
695
+ return;
696
+ case "call":
697
+ if (e.recv)
698
+ this.checkGroupExpr(e.recv, valid, where, inCall);
699
+ for (const a of e.args)
700
+ this.checkGroupExpr(a, valid, where, e.recv === null ? e.name : inCall);
701
+ return;
702
+ case "unary":
703
+ case "required":
704
+ this.checkGroupExpr(e.expr, valid, where, inCall);
705
+ return;
706
+ case "binary":
707
+ case "logical":
708
+ case "in":
709
+ this.checkGroupExpr(e.left, valid, where, inCall);
710
+ this.checkGroupExpr(e.right, valid, where, inCall);
711
+ return;
712
+ case "range":
713
+ if (e.lo)
714
+ this.checkGroupExpr(e.lo, valid, where, inCall);
715
+ if (e.hi)
716
+ this.checkGroupExpr(e.hi, valid, where, inCall);
717
+ return;
718
+ case "tuple":
719
+ e.items.forEach((x) => this.checkGroupExpr(x, valid, where, inCall));
720
+ return;
721
+ case "template":
722
+ e.parts.forEach((p) => { if (typeof p !== "string")
723
+ this.checkGroupExpr(p, valid, where, inCall); });
724
+ return;
725
+ case "op":
726
+ this.checkGroupExpr(e.receiver, valid, where, inCall);
727
+ return;
728
+ }
391
729
  }
392
730
  // The keyword-less run that opens a body. At the top level it is always the
393
731
  // projection. In a block it is a `where`-first body when the leading expression
@@ -472,6 +810,12 @@ class Parser {
472
810
  this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
473
811
  if (t.value === "frontier")
474
812
  this.fail(`unexpected 'frontier' after \`${last}\` — \`frontier\` takes an expression (\`frontier <expr>\`) and belongs to a body that has a \`follow\``);
813
+ if (t.value === "flatten")
814
+ this.fail(`unexpected 'flatten' after \`${last}\` — \`flatten\` takes a value to split into rows (\`flatten <expr> [{ … }] [as <name>]\`)`);
815
+ if (t.value === "group")
816
+ this.fail(`unexpected 'group' after \`${last}\` — a grouping is written \`group by <key>[, <key>…]\` (a key is a name or \`alias: <expr>\`)`);
817
+ if (t.value === "having")
818
+ 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\``);
475
819
  }
476
820
  if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
477
821
  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\``);
@@ -640,10 +984,8 @@ class Parser {
640
984
  ? { kind: "outer", span: [start, head.end], levels, name: head.value }
641
985
  : { kind: "ident", span: [start, head.end], name: head.value };
642
986
  let name = head.value;
643
- if (levels === 0 && this.at("lparen")) {
644
- const args = this.parseArgs();
645
- expr = { kind: "call", span: this.sp(start), recv: null, name: head.value, args };
646
- }
987
+ if (levels === 0 && this.at("lparen"))
988
+ expr = this.parseFreeCall(head.value, start);
647
989
  while (this.at("dot")) {
648
990
  this.next();
649
991
  if (!this.at("ident"))
@@ -719,8 +1061,8 @@ class Parser {
719
1061
  // `name: jobs first { }.pay`: the directive is the head of a longer value.
720
1062
  if (this.atScalarContinuation() || this.atValueDirective())
721
1063
  return this.finishItem(name, this.parseOr(op), lift, start);
722
- if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
723
- this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
1064
+ if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
1065
+ 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)`);
724
1066
  }
725
1067
  return this.finishItem(name, op, lift, start);
726
1068
  }
@@ -1049,6 +1391,10 @@ class Parser {
1049
1391
  if (!this.at("rbrace"))
1050
1392
  this.fail(`expected '}' to close the ${op} { … } block`);
1051
1393
  this.next();
1394
+ // `x collect distinct { group by k }`: the directive's distinct is as
1395
+ // redundant as the body's own (checked in parseBody).
1396
+ if (distinct && body.groupBy)
1397
+ this.failAt(body.groupBy.span[0], DISTINCT_WITH_GROUP);
1052
1398
  const sub = {
1053
1399
  kind: "subquery",
1054
1400
  span: this.sp(lbrace),
@@ -1059,6 +1405,9 @@ class Parser {
1059
1405
  follow: body.follow,
1060
1406
  frontier: body.frontier,
1061
1407
  depth: body.depth,
1408
+ flatten: body.flatten,
1409
+ groupBy: body.groupBy,
1410
+ having: body.having,
1062
1411
  values: body.values,
1063
1412
  limit: body.limit,
1064
1413
  offset: body.offset,
@@ -1130,7 +1479,7 @@ class Parser {
1130
1479
  this.next();
1131
1480
  const subSpan = [lbracket, this.end()];
1132
1481
  const sub = (parts) => ({
1133
- kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
1482
+ kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, flatten: [], groupBy: null, having: null, values: false, limit: null, offset: null, ...parts,
1134
1483
  });
1135
1484
  const bang = this.atOp("!") ? this.next() : null;
1136
1485
  if (index) {
@@ -1291,9 +1640,9 @@ class Parser {
1291
1640
  }
1292
1641
  }
1293
1642
  else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
1294
- // free function call: name(args) — a bare name read here, not `(f)(x)`
1295
- const args = this.parseArgs();
1296
- expr = { kind: "call", span: this.sp(start), recv: null, name: expr.name, args };
1643
+ // free function call: name(args) — a bare name read here, not `(f)(x)`;
1644
+ // an aggregate name makes it an `aggregate` node (0.24)
1645
+ expr = this.parseFreeCall(expr.name, start);
1297
1646
  }
1298
1647
  else if (this.at("lbracket")) {
1299
1648
  expr = this.parseBracket(expr);
@@ -1320,9 +1669,12 @@ class Parser {
1320
1669
  }
1321
1670
  return expr;
1322
1671
  }
1672
+ // The directives that have a value form: collect/first/single, and since 0.24
1673
+ // `count { … }` (the number of rows the block yields). exists/none are
1674
+ // predicates only.
1323
1675
  atValueDirective() {
1324
1676
  const op = this.atConsumerBlock();
1325
- return op === "collect" || op === "first" || op === "single";
1677
+ return op === "collect" || op === "first" || op === "single" || op === "count";
1326
1678
  }
1327
1679
  parseArgs() {
1328
1680
  this.next(); // '('
@@ -1440,6 +1792,99 @@ class Parser {
1440
1792
  return { kind: "template", span: [open.pos, this.end()], parts };
1441
1793
  }
1442
1794
  }
1795
+ // ---- grouping helpers (0.24) --------------------------------------------------
1796
+ const DISTINCT_WITH_GROUP = "`distinct` is redundant with `group by` — a grouped body already yields one row per distinct key tuple; drop `distinct`";
1797
+ // The projection of a grouped body that writes none (SEMANTICS §24): every key
1798
+ // by its name, then `count: count()`. Synthesized here so the tree shows what
1799
+ // the body projects and the engine has no grouped "row itself" to define; the
1800
+ // nodes carry the empty span (they did not come from source).
1801
+ function defaultGroupProjection(groupBy, fail) {
1802
+ if (groupBy.keys.some((k) => k.name === "count")) {
1803
+ fail("a group key named `count` needs an explicit `select` — the default projection of a grouped body is its keys plus `count: count()`");
1804
+ }
1805
+ 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 }));
1806
+ items.push({ kind: "field", span: [0, 0], name: "count", expr: { kind: "aggregate", span: [0, 0], name: "count", arg: null, distinct: false }, lift: 0 });
1807
+ return items;
1808
+ }
1809
+ // Every aggregate node in `e` read at THIS scope (a nested block's body is its
1810
+ // own scope and is not entered; a block's receiver is). An aggregate's argument
1811
+ // is entered too, so a nested aggregate is found by `nestedAggregate`.
1812
+ function aggregatesIn(e, out) {
1813
+ switch (e.kind) {
1814
+ case "aggregate":
1815
+ out.push(e);
1816
+ if (e.arg)
1817
+ aggregatesIn(e.arg, out);
1818
+ return;
1819
+ case "lit":
1820
+ case "ident":
1821
+ case "outer":
1822
+ case "binding": return;
1823
+ case "member":
1824
+ aggregatesIn(e.recv, out);
1825
+ return;
1826
+ case "call":
1827
+ if (e.recv)
1828
+ aggregatesIn(e.recv, out);
1829
+ e.args.forEach((a) => aggregatesIn(a, out));
1830
+ return;
1831
+ case "unary":
1832
+ case "required":
1833
+ aggregatesIn(e.expr, out);
1834
+ return;
1835
+ case "binary":
1836
+ case "logical":
1837
+ case "in":
1838
+ aggregatesIn(e.left, out);
1839
+ aggregatesIn(e.right, out);
1840
+ return;
1841
+ case "range":
1842
+ if (e.lo)
1843
+ aggregatesIn(e.lo, out);
1844
+ if (e.hi)
1845
+ aggregatesIn(e.hi, out);
1846
+ return;
1847
+ case "tuple":
1848
+ e.items.forEach((x) => aggregatesIn(x, out));
1849
+ return;
1850
+ case "template":
1851
+ e.parts.forEach((p) => { if (typeof p !== "string")
1852
+ aggregatesIn(p, out); });
1853
+ return;
1854
+ case "op":
1855
+ aggregatesIn(e.receiver, out);
1856
+ return;
1857
+ }
1858
+ }
1859
+ function whereAggregates(w, out) {
1860
+ switch (w.kind) {
1861
+ case "and":
1862
+ case "or":
1863
+ w.parts.forEach((p) => whereAggregates(p, out));
1864
+ return;
1865
+ case "not":
1866
+ whereAggregates(w.expr, out);
1867
+ return;
1868
+ case "scalar":
1869
+ aggregatesIn(w.expr, out);
1870
+ return;
1871
+ case "op":
1872
+ aggregatesIn(w.receiver, out);
1873
+ return;
1874
+ }
1875
+ }
1876
+ // The first aggregate whose argument holds another (`sum(count())`), if any.
1877
+ function nestedAggregate(found) {
1878
+ for (const a of found) {
1879
+ if (a.kind !== "aggregate" || !a.arg)
1880
+ continue;
1881
+ const inner = [];
1882
+ aggregatesIn(a.arg, inner);
1883
+ if (inner.length > 0)
1884
+ return a;
1885
+ }
1886
+ return null;
1887
+ }
1443
1888
  // The default key of an unaliased projection item: the last segment of a bare /
1444
1889
  // dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
1445
1890
  function navKey(e) {