@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
@@ -0,0 +1,293 @@
1
+ // The `references` static pass (spec/oqx/AST.md §5b, language 0.23): every
2
+ // NAME a query reads from a row — a bare identifier, a dotted navigation headed
3
+ // by one, an outer reference — with the clause it stands in and the SCOPE whose
4
+ // rows it is read from, described as the chain of expressions that produce
5
+ // those rows (the source, each `from` re-projection, each block receiver, each
6
+ // `follow` walk). The pass knows no data: a host that knows what rows an
7
+ // expression yields maps each scope to a row type and judges the name against
8
+ // it (omgbase: "is `target` a field of links?"). Built on `visit`, so a future
9
+ // node kind is walked here the day it is added to `CHILDREN`.
10
+ //
11
+ // What is a reference and what is not (the rules, pinned by
12
+ // `cases/references.json`):
13
+ // • a bare `ident` is a reference to the current row's property, with the
14
+ // name as written; a `member` chain headed by an `ident` or `outer` is ONE
15
+ // reference whose name is the full dotted path (`doc.layer`, `meta.owner`);
16
+ // a chain headed by anything else (a call, a block, a literal, a tuple) is
17
+ // not — its head is walked and the trailing `.name`s are ignored, since the
18
+ // head's value is not a row of any scope;
19
+ // • an `outer` (`^name`, `N^name`) is a reference read in the scope `levels`
20
+ // out; `$it` as a head is the row itself (`$it.title` ≡ `title`,
21
+ // `^$it.title` ≡ `^title`) and `$it` alone is no reference;
22
+ // • `$`-names (`$key`, `$depth`, `$stop`, …) are the language's values, never
23
+ // references; a `binding` is a host value; a function or method NAME is
24
+ // not a reference, its receiver and arguments are walked
25
+ // (`target.contains("x")` references `target`); a name a nested block
26
+ // LIFTS onto a scope (`^open: value`, SEMANTICS §19) is a binding of that
27
+ // scope, so reading it there (`select open`, `^open` from deeper) is not a
28
+ // reference either;
29
+ // • a `select` alias is not a reference where the language resolves it
30
+ // (SEMANTICS §14): in the same body's `where`, and in the items to its
31
+ // right — a bare identifier naming such an alias, or a chain headed by it,
32
+ // is skipped; an alias's own name inside its own item is the row field and
33
+ // IS a reference; `order by`, `frontier`, `limit`/`offset`, `from` and
34
+ // `follow` are not rewritten against aliases, so a name there is a
35
+ // reference whatever the `select` says;
36
+ // • a name read at the ROOT scope — the head of the top-level `source`
37
+ // (`from people`: clause `source`), an outer reference with as many carets
38
+ // as there are enclosing scopes (`^docs` from a top-level row) — is
39
+ // reported with depth 0 and no steps: it names one of the host's roots,
40
+ // and the host decides what to make of it;
41
+ // • a `follow` destination that is a plain expression is a reference read
42
+ // on the frontier row (clause `follow.destination`), like a `from`.
43
+ //
44
+ // The scope: `{ depth, steps }`. `depth` is the scope depth the name is
45
+ // evaluated at (`visit`'s depth; for an outer reference, that depth minus its
46
+ // levels). `steps` are the expressions that produce the scope's rows, root
47
+ // first, each with the depth of the rows it produces: the query's `source`
48
+ // (depth 1), each `from` re-projection in turn (the same depth — a `from`
49
+ // replaces the rows), a block's receiver (one deeper than where it is read),
50
+ // then the block's own `from`s; a `follow` node is a step too — the walk's
51
+ // frontier rows, at the body's depth, which the destinations are read on
52
+ // (a destination block's receiver is then one deeper). A name read inside the
53
+ // k-th `from` sees only the steps before it. For an outer reference the steps
54
+ // are cut after the last step at its scope's depth. The empty list is the root
55
+ // scope.
56
+
57
+ import type { AstNode, Expr, Follow, GroupBy, Query, SelectItem, Span, Subquery } from "./ast.ts";
58
+ import type { Clause } from "./walk.ts";
59
+ import { visit } from "./walk.ts";
60
+
61
+ /** One step of a scope's row chain: an expression (a source, a `from`, a block
62
+ * receiver, a plain destination), a `follow` node (the walk's frontier rows), or
63
+ * — since 0.24 — a `groupBy` node (the body's groups: the rows at the same depth
64
+ * replaced by one key record per group), with the scope depth of the rows it
65
+ * produces. */
66
+ export interface ReferenceStep {
67
+ readonly expr: Expr | Follow | GroupBy;
68
+ readonly depth: number;
69
+ }
70
+
71
+ export interface ReferenceScope {
72
+ /** The scope depth the name is read at (SEMANTICS §2; 0 = the root scope). */
73
+ readonly depth: number;
74
+ /** The row chain producing the scope's rows, root first. */
75
+ readonly steps: readonly ReferenceStep[];
76
+ }
77
+
78
+ export interface Reference {
79
+ /** The name as written — the full dotted path for a navigation (`doc.layer`), without a `$it.` head. */
80
+ readonly name: string;
81
+ readonly clause: Clause;
82
+ readonly scope: ReferenceScope;
83
+ /** The span of the whole reference (the chain, for a navigation). */
84
+ readonly span: Span;
85
+ }
86
+
87
+ /** A dotted name headed by an `ident` or `outer`: the head and the path after it. */
88
+ function dottedName(e: Expr): { head: Expr & { kind: "ident" | "outer" }; path: string[] } | null {
89
+ if (e.kind === "ident" || e.kind === "outer") return { head: e, path: [] };
90
+ if (e.kind === "member") {
91
+ const inner = dottedName(e.recv);
92
+ return inner === null ? null : { head: inner.head, path: [...inner.path, e.name] };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** The alias an item defines (SEMANTICS §14): a named `collect`, or a named non-lift field. */
98
+ function aliasName(it: SelectItem): string | null {
99
+ if (it.kind === "collect") return it.name === "" ? null : it.name;
100
+ return it.lift === 0 && it.name !== "" ? it.name : null;
101
+ }
102
+
103
+ /** Every name a query (or block body) reads from a row, in canonical source order. */
104
+ export function references(root: Query | Subquery): Reference[] {
105
+ const out: Reference[] = [];
106
+ const lifts = new Map<Query | Subquery, ReadonlySet<string>>();
107
+ visit(root, {
108
+ enter(node, ctx) {
109
+ const named = dottedName(node as Expr);
110
+ if (named === null) return;
111
+ const { head } = named;
112
+ // The head of a navigation is handled here, once, with the chain's full name.
113
+ let name = head.name;
114
+ let path = named.path;
115
+ if (name === "$it") {
116
+ if (path.length === 0) return false; // the row itself
117
+ name = path[0]!;
118
+ path = path.slice(1);
119
+ } else if (name.startsWith("$")) return false; // a language value
120
+ if (ctx.clause === null) return false; // unreachable: the root node is never a name
121
+ if (head.kind === "ident" && isResolvedAlias(name, ctx.path, ctx.clause)) return false;
122
+ const levels = head.kind === "outer" ? head.levels : 0;
123
+ const depth = ctx.depth - levels;
124
+ if (isLiftBound(name, depth, ctx.path, lifts)) return false;
125
+ // At a group scope (0.24) a bare key name is the key — an alias of the
126
+ // expression reported in clause `groupBy` — and `rows` is the language's
127
+ // value; neither reads a row.
128
+ if (isGroupValue(name, depth, ctx.path, node)) return false;
129
+ const full = [name, ...path].join(".");
130
+ const steps = scopeSteps(ctx.path, node);
131
+ const scoped = levels === 0 ? steps : cutAt(steps, depth);
132
+ out.push({ name: full, clause: ctx.clause, scope: { depth, steps: scoped }, span: node.span });
133
+ return false;
134
+ },
135
+ });
136
+ return out;
137
+ }
138
+
139
+ /** Whether a bare `name` in this position is a `select` alias the language
140
+ * substitutes (so the name is the alias, not a row property): in the body's
141
+ * `where` or `having`, any alias; in a `select` item, an alias defined to its LEFT. */
142
+ function isResolvedAlias(name: string, path: readonly AstNode[], clause: Clause): boolean {
143
+ if (clause !== "where" && clause !== "select" && clause !== "having") return false;
144
+ // The nearest body, and the item under it when we are inside its select.
145
+ for (let i = path.length - 1; i >= 0; i--) {
146
+ const a = path[i]!;
147
+ if (a.kind !== "query" && a.kind !== "subquery") continue;
148
+ const index = new Map<string, number>();
149
+ a.select.forEach((it, j) => { const n = aliasName(it); if (n !== null) index.set(n, j); });
150
+ const j = index.get(name);
151
+ if (j === undefined) return false;
152
+ if (clause === "where" || clause === "having") return true;
153
+ const item = path[i + 1];
154
+ const at = item === undefined ? -1 : a.select.indexOf(item as SelectItem);
155
+ return at >= 0 && j < at;
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * Whether `name`, read at scope `depth`, is bound there by a lift (`^name: expr`
162
+ * in a nested block, SEMANTICS §19): the lifted value is appended to that
163
+ * scope's row record, so the name is a binding, not a row property. The body
164
+ * whose rows are at `depth` is the depth-th body on the path (every nested body
165
+ * is one scope deeper than the one around it).
166
+ */
167
+ function isLiftBound(name: string, depth: number, path: readonly AstNode[], cache: Map<Query | Subquery, ReadonlySet<string>>): boolean {
168
+ if (depth < 1) return false;
169
+ const bodies = path.filter((a): a is Query | Subquery => a.kind === "query" || a.kind === "subquery");
170
+ const body = bodies[depth - 1];
171
+ if (body === undefined) return false;
172
+ let names = cache.get(body);
173
+ if (names === undefined) {
174
+ names = liftsLandingOn(body);
175
+ cache.set(body, names);
176
+ }
177
+ return names.has(name);
178
+ }
179
+
180
+ /** The names the nested blocks of `body` lift onto it: a `field` item with `lift` L in a block L scopes below it. */
181
+ function liftsLandingOn(body: Query | Subquery): ReadonlySet<string> {
182
+ const names = new Set<string>();
183
+ const own = body.kind === "subquery" ? 1 : 0;
184
+ visit(body, {
185
+ enter(node, ctx) {
186
+ if (node.kind !== "field" || node.lift === 0) return;
187
+ const below = ctx.path.filter((a) => a.kind === "subquery").length - own;
188
+ if (node.lift === below) names.add(node.name);
189
+ },
190
+ });
191
+ return names;
192
+ }
193
+
194
+ /**
195
+ * Whether `name`, read at scope `depth`, is a value of a GROUP scope there
196
+ * (SEMANTICS §24): the body whose rows are at `depth` has a `group by`, the
197
+ * position is one of its post-group clauses — a select item, `having`, `order
198
+ * by` — not inside an aggregate's argument (which reads the group's rows), and
199
+ * the name is a key or `rows`.
200
+ */
201
+ function isGroupValue(name: string, depth: number, path: readonly AstNode[], node: AstNode): boolean {
202
+ const i = bodyIndexAt(path, depth);
203
+ if (i === -1) return false;
204
+ const body = path[i] as Query | Subquery;
205
+ if (!atGroupScope(body, path, i, node)) return false;
206
+ return name === "rows" || body.groupBy!.keys.some((k) => k.name === name);
207
+ }
208
+
209
+ /** The index in `path` of the body whose rows are at scope `depth` (every nested body is one scope deeper), or -1. */
210
+ function bodyIndexAt(path: readonly AstNode[], depth: number): number {
211
+ if (depth < 1) return -1;
212
+ let seen = 0;
213
+ for (let i = 0; i < path.length; i++) {
214
+ const a = path[i]!;
215
+ if (a.kind !== "query" && a.kind !== "subquery") continue;
216
+ if (++seen === depth) return i;
217
+ }
218
+ return -1;
219
+ }
220
+
221
+ /** Whether a position under `body` (at `path[i]`) reads the body's GROUP scope:
222
+ * the body is grouped, the position lies in a select item, `having` or `order
223
+ * by`, and no aggregate stands between the body and the position (an
224
+ * aggregate's argument reads the group's rows; a nested body is its own scope
225
+ * and ends the search). */
226
+ function atGroupScope(body: Query | Subquery, path: readonly AstNode[], i: number, node: AstNode): boolean {
227
+ if (body.groupBy === null) return false;
228
+ const child = path[i + 1] ?? node;
229
+ const post = body.select.includes(child as SelectItem) || child === body.having || (body.orderBy?.includes(child as never) ?? false);
230
+ if (!post) return false;
231
+ for (let j = i + 1; j < path.length; j++) {
232
+ const a = path[j]!;
233
+ if (a.kind === "query" || a.kind === "subquery") break;
234
+ if (a.kind === "aggregate") return false;
235
+ }
236
+ return true;
237
+ }
238
+
239
+ /** The row chain of the scope a node is evaluated in, from its ancestors. */
240
+ function scopeSteps(path: readonly AstNode[], node: AstNode): ReferenceStep[] {
241
+ const steps: ReferenceStep[] = [];
242
+ let depth = 0;
243
+ for (let i = 0; i < path.length; i++) {
244
+ const a = path[i]!;
245
+ const next: AstNode = path[i + 1] ?? node;
246
+ switch (a.kind) {
247
+ case "query": {
248
+ if (next === a.source || next === a.limit || next === a.offset) break; // read at the root scope
249
+ depth = 1;
250
+ steps.push({ expr: a.source, depth });
251
+ pushFroms(steps, a.from, next, depth);
252
+ // A grouped body's post-group clauses read its groups (0.24): the rows
253
+ // at this depth replaced by one key record per group.
254
+ if (atGroupScope(a, path, i, node)) steps.push({ expr: a.groupBy!, depth });
255
+ break;
256
+ }
257
+ case "subquery": {
258
+ // The receiver step was pushed by the enclosing `op`; this body's rows
259
+ // are one deeper than where that receiver was read.
260
+ pushFroms(steps, a.from, next, depth);
261
+ if (atGroupScope(a, path, i, node)) steps.push({ expr: a.groupBy!, depth });
262
+ break;
263
+ }
264
+ case "op": {
265
+ if (next !== a.sub) break; // inside the receiver: read where the block is read
266
+ depth += 1;
267
+ steps.push({ expr: a.receiver, depth });
268
+ break;
269
+ }
270
+ case "follow": {
271
+ // The destinations are read on the walk's frontier rows, at the body's depth.
272
+ steps.push({ expr: a, depth });
273
+ break;
274
+ }
275
+ default: break;
276
+ }
277
+ }
278
+ return steps;
279
+ }
280
+
281
+ /** The `from` re-projections that apply at `next`: all of them unless `next` is one of them, in which case those before it. */
282
+ function pushFroms(steps: ReferenceStep[], from: readonly Expr[], next: AstNode, depth: number): void {
283
+ const at = from.indexOf(next as Expr);
284
+ const upto = at === -1 ? from.length : at;
285
+ for (let k = 0; k < upto; k++) steps.push({ expr: from[k]!, depth });
286
+ }
287
+
288
+ /** The steps up to and including the last one producing rows at `depth` (none for the root scope). */
289
+ function cutAt(steps: readonly ReferenceStep[], depth: number): ReferenceStep[] {
290
+ let end = 0;
291
+ steps.forEach((s, i) => { if (s.depth === depth) end = i + 1; });
292
+ return steps.slice(0, end);
293
+ }
package/src/resolve.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  // given. Untouched nodes are returned by reference, so a cached query keeps the
27
27
  // identity of every block the rewrite did not reach.
28
28
 
29
- import type { Expr, Follow, FollowDestination, OpNode, Query, SelectItem, Subquery, Where } from "./ast.ts";
29
+ import type { Expr, Follow, FollowDestination, GroupBy, OpNode, Query, SelectItem, Subquery, Where } from "./ast.ts";
30
30
  import { OqxError } from "./errors.ts";
31
31
 
32
32
  /** How a resolution failure is reported: the parser raises its positioned parse
@@ -52,8 +52,15 @@ function resolveBody<T extends Query | Subquery>(node: T, fail: Fail): T {
52
52
  // cycle report follows the where's own path (`c → a → b → c`), as pinned.
53
53
  const where = node.where === null ? null : resolveWhere(node.select, node.where, fail);
54
54
  const where2 = where === null ? null : resolveNestedWhere(where, fail);
55
+ // `having` (0.24) is the post-group `where`: rewritten against the select
56
+ // exactly as `where` is (`select n: count() … having n > 1`).
57
+ const having = node.having === null ? null : resolveWhere(node.select, node.having, fail);
58
+ const having2 = having === null ? null : resolveNestedWhere(having, fail);
55
59
  const select = resolveSelect(node.select, fail);
56
60
  const follow = node.follow === null ? null : resolveFollow(node.follow, fail);
61
+ // The keys are read on the rows, not rewritten (a key IS the name the post-group
62
+ // clauses read); their nested blocks resolve against their own select.
63
+ const groupBy = node.groupBy === null ? null : resolveGroupBy(node.groupBy, fail);
57
64
  const from = mapSame(node.from, (e) => resolveExpr(e, fail));
58
65
  const orderBy = node.orderBy === null ? null : mapSame(node.orderBy, (o) => {
59
66
  const expr = resolveExpr(o.expr, fail);
@@ -68,13 +75,22 @@ function resolveBody<T extends Query | Subquery>(node: T, fail: Fail): T {
68
75
  const source = node.kind === "query" ? resolveExpr(node.source, fail) : null;
69
76
  const same = where2 === node.where && select === node.select && follow === node.follow && from === node.from
70
77
  && orderBy === node.orderBy && limit === node.limit && offset === node.offset && frontier === node.frontier
78
+ && groupBy === node.groupBy && having2 === node.having
71
79
  && (node.kind !== "query" || source === node.source);
72
80
  if (same) return node;
73
- const out = { ...node, where: where2, select, follow, frontier, from, orderBy, limit, offset };
81
+ const out = { ...node, where: where2, select, follow, frontier, groupBy, having: having2, from, orderBy, limit, offset };
74
82
  if (node.kind === "query") (out as Query).source = source!;
75
83
  return out;
76
84
  }
77
85
 
86
+ function resolveGroupBy(g: GroupBy, fail: Fail): GroupBy {
87
+ const keys = mapSame(g.keys, (k) => {
88
+ const expr = resolveExpr(k.expr, fail);
89
+ return expr === k.expr ? k : { ...k, expr };
90
+ });
91
+ return keys === g.keys ? g : { ...g, keys };
92
+ }
93
+
78
94
  function resolveOp(op: OpNode, fail: Fail): OpNode {
79
95
  const receiver = resolveExpr(op.receiver, fail);
80
96
  const sub = resolveBody(op.sub, fail);
@@ -145,6 +161,10 @@ function mapExpr(e: Expr, f: (e: Expr) => Expr | null): Expr {
145
161
  const parts = mapSame(e.parts, (p) => (typeof p === "string" ? p : go(p)));
146
162
  return parts === e.parts ? e : { ...e, parts };
147
163
  }
164
+ case "aggregate": {
165
+ const arg = e.arg ? go(e.arg) : null;
166
+ return arg === e.arg ? e : { ...e, arg };
167
+ }
148
168
  case "op": { // the receiver is read in this scope; the block is its own scope
149
169
  const receiver = go(e.receiver);
150
170
  return receiver === e.receiver ? e : { ...e, receiver };
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /** The OQX language version (`spec/oqx/VERSION`, `major.minor`) this implementation
2
2
  * conforms to — distinct from the package's own semver, whose `major.minor` it
3
3
  * equals. Stamped on every `toJSON` document. */
4
- export const LANGUAGE_VERSION = "0.21";
4
+ export const LANGUAGE_VERSION = "0.24";
package/src/walk.ts CHANGED
@@ -14,7 +14,8 @@ import { LANGUAGE_VERSION } from "./version.ts";
14
14
  * The root node has no clause (`null`). */
15
15
  export type Clause =
16
16
  | "source" | "from" | "where" | "select" | "orderBy" | "limit" | "offset"
17
- | "follow" | "follow.destination" | "follow.frontier";
17
+ | "follow" | "follow.destination" | "follow.frontier"
18
+ | "groupBy" | "having"; // the `group by` keys and the post-group predicate (0.24)
18
19
 
19
20
  /** Where a visited node sits. `depth` is the scope depth at which the node is
20
21
  * evaluated (0 = the root scope: a top-level `source`, `limit`, `offset`; 1 =
@@ -57,6 +58,10 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
57
58
  // The body's `frontier` (0.20) reads each seed in the row scope, like `where`.
58
59
  { key: "frontier", clause: "follow.frontier", depth: 1 },
59
60
  { key: "follow", clause: "follow", depth: 1 },
61
+ // `group by` keys are read on the rows, `having` on the groups — both at
62
+ // the row depth (the group scope sits where the rows did; 0.24).
63
+ { key: "groupBy", clause: "groupBy", depth: 1 },
64
+ { key: "having", clause: "having", depth: 1 },
60
65
  { key: "orderBy", clause: "orderBy", depth: 1 },
61
66
  { key: "limit", clause: "limit" },
62
67
  { key: "offset", clause: "offset" },
@@ -71,12 +76,16 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
71
76
  // (`childDepth` tells the two apart by the block's position).
72
77
  { key: "frontier", clause: "follow.frontier", depth: -1 },
73
78
  { key: "follow", clause: "follow" },
79
+ { key: "groupBy", clause: "groupBy" },
80
+ { key: "having", clause: "having" },
74
81
  { key: "orderBy", clause: "orderBy" },
75
82
  { key: "limit", clause: "limit" },
76
83
  { key: "offset", clause: "offset" },
77
84
  ],
78
85
  op: [{ key: "receiver" }, { key: "sub", depth: 1 }],
79
86
  follow: [{ key: "destinations", clause: "follow.destination" }],
87
+ groupBy: [{ key: "keys" }],
88
+ key: [{ key: "expr" }],
80
89
  order: [{ key: "expr" }],
81
90
  field: [{ key: "expr" }],
82
91
  collect: [{ key: "op" }],
@@ -99,6 +108,8 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
99
108
  tuple: [{ key: "items" }],
100
109
  // The interpolations; the text parts between them are strings, not nodes.
101
110
  template: [{ key: "parts" }],
111
+ // The argument is read on each of the group's rows (0.24); `null` for `count()`.
112
+ aggregate: [{ key: "arg" }],
102
113
  };
103
114
 
104
115
  /** A child slot's value: a node, a list of nodes (a template's `parts` also