@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/src/engine.ts CHANGED
@@ -65,6 +65,20 @@ interface Scope {
65
65
  run: RunState;
66
66
  lifts?: Record<string, unknown>;
67
67
  meta?: Record<string, unknown>;
68
+ /** The row as it arrived, when `row` is its unwrapped form (an entry's value):
69
+ * what the group's `rows` hands back so a re-entered row is the same row. */
70
+ raw?: unknown;
71
+ /** Set on a GROUP scope (SEMANTICS §24): the scope's row is the key record,
72
+ * `rows` the group's rows, and the aggregates reduce `rowScopes`. */
73
+ group?: Group;
74
+ }
75
+
76
+ // One group of a `group by` body: the scopes of its rows (for the aggregates,
77
+ // which read each row as the body's `where` did — lifts and walk intrinsics
78
+ // included) and the rows themselves (what `rows` yields).
79
+ interface Group {
80
+ rowScopes: Scope[];
81
+ rows: unknown[];
68
82
  }
69
83
 
70
84
  // State that lives for exactly one `run()` (nothing here outlives it, so data
@@ -117,11 +131,16 @@ export interface InMemoryEngineOptions {
117
131
 
118
132
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
119
133
  const KEY = "$key";
134
+ const ROWS = "rows";
120
135
  const HARD_DEPTH_CAP = 8;
121
136
 
122
137
  // What a scope projects to: the select list plus the `values` mode flag. Both
123
138
  // `Query` and `Subquery` carry this shape.
124
139
  type Projection = Pick<Subquery, "select" | "values">;
140
+ // The grouping clauses of a body (0.24). Both `Query` and `Subquery` carry them.
141
+ type Grouping = Pick<Subquery, "groupBy" | "having">;
142
+ // The `flatten` clauses of a body (0.25). Both `Query` and `Subquery` carry them.
143
+ type Flattening = Pick<Subquery, "flatten">;
125
144
 
126
145
  // The `$key: expr` item of a projection, if any — the identity the rows it
127
146
  // yields carry (SEMANTICS §16, since 0.20).
@@ -165,17 +184,19 @@ export class InMemoryEngine implements Engine {
165
184
  const src = this.coerceRows(this.evalRaw(query.source, root), query.source);
166
185
  const rows = this.reproject(src.rows, query.from, root);
167
186
  // One object as the source yields one record (SEMANTICS §3) — unless a
168
- // `from` re-projection or a walk fans it out.
169
- const single = src.single && query.from.length === 0 && !query.follow;
187
+ // `from` re-projection, a walk or a `flatten` fans it out.
188
+ const single = src.single && query.from.length === 0 && !query.follow && query.flatten.length === 0;
170
189
 
171
190
  const bound = this.boundOf(query, root);
172
191
  if (query.follow) return this.runFollow(query, rows, root, bound);
173
192
 
174
193
  // Consumer-directed short-circuits (skipped under `distinct`, which must
175
- // materialize + dedup by projection before reducing). Counting never
176
- // materializes rows; exists/none stop as soon as the bound is known to be
177
- // non-empty (the (offset+1)th match — or the first, when unbounded).
178
- if (!query.distinct && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
194
+ // materialize + dedup by projection before reducing, under `group by`,
195
+ // whose consumer counts groups, and under `flatten`, whose consumer counts
196
+ // flattened rows). Counting never materializes rows; exists/none stop as
197
+ // soon as the bound is known to be non-empty (the (offset+1)th match — or
198
+ // the first, when unbounded).
199
+ if (!query.distinct && !query.groupBy && query.flatten.length === 0 && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
179
200
  const need = query.consumer === "count" ? Infinity : bound.offset + 1;
180
201
  let n = 0;
181
202
  for (const r of rows) {
@@ -190,7 +211,7 @@ export class InMemoryEngine implements Engine {
190
211
  // first over an unordered, non-distinct set needs only the rows up to the
191
212
  // bound (offset + 1). single materializes everything so its error can
192
213
  // report how many rows actually matched.
193
- const cap = query.consumer === "first" && !query.orderBy && !query.distinct
214
+ const cap = query.consumer === "first" && !query.orderBy && !query.distinct && !query.groupBy && query.flatten.length === 0
194
215
  ? bound.offset + Math.min(1, bound.limit ?? 1)
195
216
  : Infinity;
196
217
  let kept: Scope[] = [];
@@ -201,6 +222,8 @@ export class InMemoryEngine implements Engine {
201
222
  if (kept.length >= cap) break;
202
223
  }
203
224
  }
225
+ if (query.flatten.length > 0) kept = this.flattenScopes(kept, query);
226
+ if (query.groupBy) kept = this.groupScopes(kept, query, root);
204
227
  this.applyKey(kept, query);
205
228
  this.sortScopes(kept, query.orderBy);
206
229
  if (query.distinct) kept = this.dedupByProjection(kept, query);
@@ -303,12 +326,139 @@ export class InMemoryEngine implements Engine {
303
326
  if (extra.lifts) s.lifts = extra.lifts;
304
327
  if (extra.meta) s.meta = extra.meta;
305
328
  if (isEntry(row)) {
329
+ s.raw = row;
306
330
  s.row = row.value;
307
331
  s.meta = { ...(s.meta ?? {}), [KEY]: row.key };
308
332
  }
309
333
  return s;
310
334
  }
311
335
 
336
+ // ---- flatten (SEMANTICS §25) -----------------------------------------------
337
+
338
+ // Each `flatten` clause, in writing order, fans the scopes out: the expression
339
+ // is read in the row's scope (as `where` is) and its value split — an array
340
+ // (or a host collection) → one scope per element in collection order, so an
341
+ // empty collection yields none; absent → one scope with the name absent;
342
+ // anything else (an object, a scalar, a range) → one scope with the value.
343
+ // A flattened scope IS the parent scope — the same row, parent, lifts and
344
+ // intrinsics, so `$it`, `^` and `$depth` are unchanged — with the name bound
345
+ // beside the lifts (step 4 of §2: it shadows a same-named row property) and
346
+ // `$key` the pair `[parent identity, element identity]` (§16): an element's
347
+ // identity is the host's (an entry's key), else its structural value.
348
+ private flattenScopes(scopes: Scope[], body: Flattening): Scope[] {
349
+ for (const f of body.flatten) {
350
+ const out: Scope[] = [];
351
+ for (const s of scopes) {
352
+ const parentKey = this.scopeIdentity(s);
353
+ for (const elem of this.flattenElements(this.evalRaw(f.expr, s))) {
354
+ const key = [parentKey, elem == null ? null : this.hostIdentity(elem)];
355
+ out.push({ ...s, lifts: { ...(s.lifts ?? {}), [f.name]: elem }, meta: { ...(s.meta ?? {}), [KEY]: key } });
356
+ }
357
+ }
358
+ scopes = out;
359
+ }
360
+ return scopes;
361
+ }
362
+
363
+ // The elements a flattened value splits into (§25): §3's coercion with one
364
+ // softening — a scalar is one element, not an error, since no rows are
365
+ // sourced from it — and absent as one absent element rather than none.
366
+ private flattenElements(v: unknown): unknown[] {
367
+ if (v == null) return [undefined];
368
+ if (typeof v !== "object" || isRange(v) || isEntry(v)) return [v];
369
+ if (Array.isArray(v)) return v;
370
+ const single = this.ctx.isSingle ? this.ctx.isSingle(v) : !(Symbol.iterator in v);
371
+ return single ? [v] : Array.from(this.ctx.toRows(v));
372
+ }
373
+
374
+ // ---- grouping (SEMANTICS §24) ----------------------------------------------
375
+
376
+ // The kept scopes of a body, one GROUP scope per distinct key tuple, in order
377
+ // of first appearance; then `having` over each group. A group scope sits where
378
+ // the rows did (same parent, same depth): its row is the key record — one
379
+ // property per key, in key order, holding the first row's value — so bare
380
+ // names read the keys, `$it` is the record and `$key` its structural identity;
381
+ // `rows` yields the group's rows and the aggregates reduce their scopes. Key
382
+ // values compare as identities (§16): absent ≡ null, a host row by its id,
383
+ // anything else structurally.
384
+ private groupScopes(scopes: Scope[], body: Grouping, parent: Scope): Scope[] {
385
+ const g = body.groupBy!;
386
+ const groups = new Map<string, Scope>();
387
+ for (const s of scopes) {
388
+ const record: Record<string, unknown> = {};
389
+ const ids: string[] = [];
390
+ for (const k of g.keys) {
391
+ const v = this.evalExpr(k.expr, s);
392
+ record[k.name] = v;
393
+ ids.push(canonicalKey(this.hostIdentity(v)));
394
+ }
395
+ const id = ids.join("");
396
+ let gs = groups.get(id);
397
+ if (!gs) {
398
+ gs = { row: record, parent, bindings: parent.bindings, run: parent.run, lifts: {}, group: { rowScopes: [], rows: [] } };
399
+ groups.set(id, gs);
400
+ }
401
+ gs.group!.rowScopes.push(s);
402
+ gs.group!.rows.push(s.raw !== undefined ? s.raw : s.row);
403
+ }
404
+ const out = [...groups.values()];
405
+ return body.having ? out.filter((gs) => this.evalWhere(body.having!, gs)) : out;
406
+ }
407
+
408
+ // An aggregate (§24) over the group of the scope it is read in. The argument
409
+ // is read on each row's scope in group order; every value aggregate sees the
410
+ // PRESENT values only (`count()` alone counts rows), `distinct` dedups them by
411
+ // identity (§16), and `any`/`all` test truthiness (absent is false) and stop
412
+ // at the first row that decides, like `||` / `&&`.
413
+ private aggregate(e: Extract<Expr, { kind: "aggregate" }>, scope: Scope): unknown {
414
+ const g = scope.group;
415
+ if (!g) throw new OqxError(`\`${describe(e)}\` is an aggregate and is read on a group — it needs a \`group by\` in its body`, "eval");
416
+ if (e.arg === null) return g.rowScopes.length;
417
+ const arg = e.arg;
418
+ if (e.name === "any") return g.rowScopes.some((s) => truthy(this.evalExpr(arg, s)));
419
+ if (e.name === "all") return g.rowScopes.every((s) => truthy(this.evalExpr(arg, s)));
420
+ let values: unknown[] = [];
421
+ for (const s of g.rowScopes) {
422
+ const v = this.evalExpr(arg, s);
423
+ if (v != null) values.push(v);
424
+ }
425
+ if (e.distinct) {
426
+ const seen = new Set<string>();
427
+ values = values.filter((v) => {
428
+ const k = canonicalKey(this.hostIdentity(v));
429
+ if (seen.has(k)) return false;
430
+ seen.add(k);
431
+ return true;
432
+ });
433
+ }
434
+ switch (e.name) {
435
+ case "count": return values.length;
436
+ case "collect": return values;
437
+ case "first": return values.length > 0 ? values[0] : undefined;
438
+ case "last": return values.length > 0 ? values[values.length - 1] : undefined;
439
+ case "sum": case "avg": {
440
+ let total = 0;
441
+ for (const v of values) {
442
+ if (typeof v !== "number") {
443
+ throw new OqxError(`cannot ${e.name === "sum" ? "sum" : "average"} \`${describe(arg)}\` over the group: it is ${describeValueKind(v)} — \`${e.name}\` takes numbers`, "eval");
444
+ }
445
+ total += v;
446
+ }
447
+ if (e.name === "sum") return total;
448
+ return values.length === 0 ? undefined : total / values.length;
449
+ }
450
+ case "min": case "max": {
451
+ if (values.length === 0) return undefined;
452
+ let best = values[0];
453
+ for (let i = 1; i < values.length; i++) {
454
+ const c = compare(values[i], best);
455
+ if (c !== undefined && (e.name === "min" ? c < 0 : c > 0)) best = values[i];
456
+ }
457
+ return best;
458
+ }
459
+ }
460
+ }
461
+
312
462
  // ---- follow ---------------------------------------------------------------
313
463
 
314
464
  private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
@@ -317,6 +467,8 @@ export class InMemoryEngine implements Engine {
317
467
  const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
318
468
  let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
319
469
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
470
+ if (query.flatten.length > 0) scopes = this.flattenScopes(scopes, query);
471
+ if (query.groupBy) scopes = this.groupScopes(scopes, query, root);
320
472
  this.sortScopes(scopes, query.orderBy);
321
473
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
322
474
  scopes = sliceBound(scopes, bound);
@@ -540,9 +692,11 @@ export class InMemoryEngine implements Engine {
540
692
  // rows, and the consumer only needs their count after the bound.
541
693
  n = boundedCount(acc.rows.length, bound);
542
694
  scope.run.trace?.({ kind: "cardinality", count: n });
543
- } else if (op.op !== "count" && bound === UNBOUNDED) {
695
+ } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy && op.sub.flatten.length === 0) {
544
696
  // Unbounded exists/none: stop at the first match (dedup cannot change
545
- // emptiness). Bounded: the offset/limit decide emptiness, so materialize.
697
+ // emptiness). Bounded, grouped (`having` may empty the groups) or
698
+ // flattened (an empty collection yields no row): the offset/limit
699
+ // decide emptiness, so materialize.
546
700
  n = this.anyMatch(op, scope, acc) ? 1 : 0;
547
701
  } else n = this.opRows(op, scope, bound, acc).length;
548
702
  if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
@@ -567,9 +721,11 @@ export class InMemoryEngine implements Engine {
567
721
  return this.opRows(op, scope, bound).length > 0;
568
722
  }
569
723
 
570
- // The rows a consumer op reduces: matched → ordered → distinct → bounded.
724
+ // The rows a consumer op reduces: matched → flattened → grouped → ordered → distinct → bounded.
571
725
  private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
572
726
  let scopes = this.matchRows(op, scope, acc);
727
+ if (op.sub.flatten.length > 0) scopes = this.flattenScopes(scopes, op.sub);
728
+ if (op.sub.groupBy) scopes = this.groupScopes(scopes, op.sub, scope);
573
729
  this.applyKey(scopes, op.sub);
574
730
  this.sortScopes(scopes, op.sub.orderBy);
575
731
  if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
@@ -654,9 +810,9 @@ export class InMemoryEngine implements Engine {
654
810
  }
655
811
 
656
812
  // The plain scan: the rows, re-projected by `from`, with the whole `where`.
657
- // A `from` re-projection fans a single receiver out: the block is plural.
813
+ // A `from` re-projection or a `flatten` fans a single receiver out: the block is plural.
658
814
  private scan(src: Rows, op: OpNode, scope: Scope): Access {
659
- return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 };
815
+ return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 && op.sub.flatten.length === 0 };
660
816
  }
661
817
 
662
818
  private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
@@ -758,6 +914,8 @@ export class InMemoryEngine implements Engine {
758
914
  // Over a single receiver (one object) a `collect` is the record or absent,
759
915
  // not a one-element list (SEMANTICS §3).
760
916
  case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
917
+ // `count { … }` as a value (0.24): the number of rows the block yields.
918
+ case "count": return scopes.length;
761
919
  default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : null;
762
920
  }
763
921
  }
@@ -778,6 +936,8 @@ export class InMemoryEngine implements Engine {
778
936
  const { rows, where } = this.access(op, plan, scope);
779
937
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
780
938
  scopes = this.followWalk(this.keyed(seeds, sub, scope), sub.follow, destination ? null : sub.frontier, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
939
+ if (sub.flatten.length > 0) scopes = this.flattenScopes(scopes, sub);
940
+ if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
781
941
  this.sortScopes(scopes, sub.orderBy);
782
942
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
783
943
  scopes = sliceBound(scopes, bound);
@@ -787,7 +947,7 @@ export class InMemoryEngine implements Engine {
787
947
  scopes = this.opRows(op, scope, bound, acc);
788
948
  }
789
949
  switch (op.op) {
790
- case "collect": return { scopes, single };
950
+ case "collect": case "count": return { scopes, single };
791
951
  case "first": return { scopes: scopes.slice(0, 1), single };
792
952
  case "single":
793
953
  if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
@@ -934,6 +1094,8 @@ export class InMemoryEngine implements Engine {
934
1094
  }
935
1095
  return absent ? undefined : out;
936
1096
  }
1097
+ // An aggregate (SEMANTICS §24) over the group of the current scope.
1098
+ case "aggregate": return this.aggregate(e, scope);
937
1099
  }
938
1100
  }
939
1101
 
@@ -965,6 +1127,8 @@ export class InMemoryEngine implements Engine {
965
1127
  if (name === "$it") return scope.row;
966
1128
  // `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
967
1129
  if (name === KEY) return this.scopeIdentity(scope);
1130
+ // `rows` on a group scope is the group's rows (§24); elsewhere a property.
1131
+ if (name === ROWS && scope.group) return scope.group.rows;
968
1132
  if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
969
1133
  if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
970
1134
  if (scope.parent === null) return this.ctx.root(name);
@@ -1090,6 +1254,14 @@ function describe(e: Expr): string {
1090
1254
  return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
1091
1255
  }
1092
1256
 
1257
+ // The kind of a value, for an aggregate's error: `a string ("abc")`, `a list`, …
1258
+ function describeValueKind(v: unknown): string {
1259
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return `a ${typeof v} (${JSON.stringify(v)})`;
1260
+ if (isRange(v)) return "a range";
1261
+ if (Array.isArray(v) || (typeof v === "object" && v !== null && !isEntry(v) && Symbol.iterator in v)) return "a list";
1262
+ return "a record";
1263
+ }
1264
+
1093
1265
  function describeReceiver(e: Expr): string {
1094
1266
  if (e.kind === "ident") return e.name;
1095
1267
  if (e.kind === "member") return `${describeReceiver(e.recv)}.${e.name}`;
@@ -1130,6 +1302,7 @@ function exprHasRecur(e: Expr): boolean {
1130
1302
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1131
1303
  case "tuple": return e.items.some(exprHasRecur);
1132
1304
  case "template": return e.parts.some((p) => typeof p !== "string" && exprHasRecur(p));
1305
+ case "aggregate": return e.arg !== null && exprHasRecur(e.arg);
1133
1306
  default: return false;
1134
1307
  }
1135
1308
  }
package/src/index.ts CHANGED
@@ -53,7 +53,7 @@ export { IndexedCollection } from "./adapters/indexed.ts";
53
53
  export { ROWS_ROOT, partitionPushable, residualQuery, asEquality, isConst, constValue } from "./plan.ts";
54
54
  export * as semantics from "./semantics.ts";
55
55
  export type * from "./ast.ts";
56
- export { isExpr, isRegexOp, REGEX_OPS } from "./ast.ts";
56
+ export { isExpr, isRegexOp, REGEX_OPS, isAggregateName, AGGREGATE_NAMES } from "./ast.ts";
57
57
  export { parseTemplate } from "./parser.ts";
58
58
  export { rawSource, toUtf16, codePointLength } from "./lexer.ts";
59
59
  export { resolveAliases } from "./resolve.ts";
@@ -37,6 +37,8 @@ export function exprReadsScopeIn(e: Expr, at: number, upTo: number): boolean {
37
37
  case "range": return (e.lo !== null && exprReadsScopeIn(e.lo, at, upTo)) || (e.hi !== null && exprReadsScopeIn(e.hi, at, upTo));
38
38
  case "tuple": return e.items.some((x) => exprReadsScopeIn(x, at, upTo));
39
39
  case "template": return e.parts.some((p) => typeof p !== "string" && exprReadsScopeIn(p, at, upTo));
40
+ // The argument is read on the group's rows, which sit at the group's depth.
41
+ case "aggregate": return e.arg !== null && exprReadsScopeIn(e.arg, at, upTo);
40
42
  }
41
43
  }
42
44
 
@@ -70,6 +72,11 @@ function subReadsScopeIn(sub: Subquery, at: number, upTo: number, destination: b
70
72
  if (sub.limit && exprReadsScopeIn(sub.limit, at, upTo)) return true;
71
73
  if (sub.offset && exprReadsScopeIn(sub.offset, at, upTo)) return true;
72
74
  if (sub.frontier && exprReadsScopeIn(sub.frontier, destination ? at - 1 : at, upTo)) return true;
75
+ // A `flatten` expression is read on each row (0.25); its block is an op read there.
76
+ if (sub.flatten.some((f) => exprReadsScopeIn(f.expr, at, upTo))) return true;
77
+ // `group by` keys are read on the rows, `having` on the groups — both at `at` (0.24).
78
+ if (sub.groupBy?.keys.some((k) => exprReadsScopeIn(k.expr, at, upTo))) return true;
79
+ if (sub.having && whereReadsScopeIn(sub.having, at, upTo)) return true;
73
80
  return sub.follow !== null && followReadsScopeIn(sub.follow, at, upTo);
74
81
  }
75
82
 
@@ -109,6 +116,9 @@ export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
109
116
  // A template literal raises on a list or record interpolation (§7b), which
110
117
  // only the data decides: with an interpolation it is never raise-free.
111
118
  case "template": return e.parts.every((p) => typeof p === "string");
119
+ // `sum`/`avg` raise on a non-number (§24), which only the data decides;
120
+ // and an aggregate outside a group scope raises.
121
+ case "aggregate": return false;
112
122
  }
113
123
  }
114
124
 
@@ -133,11 +143,16 @@ export function whereRaiseFree(w: Where, ctx: RuleContext): boolean {
133
143
  export function opRaiseFree(op: OpNode, ctx: RuleContext): boolean {
134
144
  if (op.op !== "exists" && op.op !== "none" && op.op !== "count") return false;
135
145
  const sub = op.sub;
136
- if (sub.follow || sub.limit || sub.offset || op.distinct) return false;
146
+ // A grouped block evaluates its keys, `having` and the aggregates of its
147
+ // projection per group (a `sum` over a string raises): never raise-free.
148
+ if (sub.follow || sub.limit || sub.offset || op.distinct || sub.groupBy) return false;
137
149
  if (!exprRaiseFree(op.receiver, ctx)) return false;
138
150
  if (!sub.from.every((e) => exprRaiseFree(e, ctx))) return false;
139
151
  if (sub.where && !whereRaiseFree(sub.where, ctx)) return false;
140
152
  if (sub.orderBy && !sub.orderBy.every((o) => exprRaiseFree(o.expr, ctx))) return false;
153
+ // A `flatten` value is coerced without error (a scalar is one row, §25), so
154
+ // the clause raises only when its expression does.
155
+ if (!sub.flatten.every((f) => exprRaiseFree(f.expr, ctx))) return false;
141
156
  return !opHasLifts(op);
142
157
  }
143
158
 
@@ -152,6 +167,8 @@ export function opHasLifts(op: OpNode): boolean {
152
167
  function subHasLifts(sub: Subquery): boolean {
153
168
  if (sub.select.some((item) => (item.kind === "field" ? item.lift > 0 : opHasLifts(item.op)))) return true;
154
169
  if (sub.where && whereHasLifts(sub.where)) return true;
170
+ if (sub.having && whereHasLifts(sub.having)) return true;
171
+ if (sub.flatten.some((f) => f.expr.kind === "op" && opHasLifts(f.expr))) return true;
155
172
  if (sub.follow?.destinations.some((d) => d.kind === "op" && opHasLifts(d))) return true;
156
173
  return false;
157
174
  }
@@ -10,7 +10,7 @@
10
10
  // cannot be built, a receiver that is not stable) always degrade to the plain
11
11
  // scan the plan started from.
12
12
 
13
- import type { Consumer, Expr, Follow, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
13
+ import type { Consumer, Expr, Flatten, Follow, GroupBy, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
14
14
 
15
15
  /** One hoisted equality `local == outer` from the block's top-level `&&`
16
16
  * conjunction: `local` reads only the block's row (an identifier or member
@@ -45,6 +45,13 @@ export interface BlockPlan {
45
45
  readonly distinct: boolean;
46
46
  readonly bound: { readonly limit: Expr | null; readonly offset: Expr | null };
47
47
  readonly follow: Follow | null;
48
+ /** `flatten` clauses (0.25): the matched rows are fanned out per element
49
+ * before the consumer sees them, so a consumer counts FLATTENED rows. */
50
+ readonly flatten: readonly Flatten[];
51
+ /** `group by` / `having` (0.24): the matched rows are grouped before the
52
+ * consumer sees them, so a consumer counts GROUPS, never rows. */
53
+ readonly groupBy: GroupBy | null;
54
+ readonly having: Where | null;
48
55
  /** Equalities answered by an index probe on the receiver (in conjunct order). */
49
56
  readonly correlated: readonly Correlation[];
50
57
  /** The conjuncts still evaluated per row once the probe has selected its
@@ -90,6 +97,9 @@ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
90
97
  distinct: node.distinct,
91
98
  bound: { limit: sub.limit, offset: sub.offset },
92
99
  follow: sub.follow,
100
+ flatten: sub.flatten,
101
+ groupBy: sub.groupBy,
102
+ having: sub.having,
93
103
  correlated: [],
94
104
  residual: sub.where,
95
105
  receiverStable: false,
@@ -112,8 +112,10 @@ export const invariantBlock: Rule = (plan) => {
112
112
  //
113
113
  // Precondition. A where-position `exists`, `none` or `count` without `distinct`
114
114
  // (dedup projects every row), without `order by` (`count` evaluates the keys),
115
- // without `follow`, and with nothing left to evaluate per row: either the block
116
- // has no `where`, or every conjunct is correlated (so the probe's bucket is the
115
+ // without `follow`, without `group by` (the consumer counts groups, not rows —
116
+ // 0.24), without `flatten` (the consumer counts flattened rows — 0.25), and
117
+ // with nothing left to evaluate per row: either the block has no
118
+ // `where`, or every conjunct is correlated (so the probe's bucket is the
117
119
  // matched set).
118
120
  //
119
121
  // Argument. With no residual predicate the matched rows are exactly the
@@ -127,7 +129,7 @@ export const invariantBlock: Rule = (plan) => {
127
129
  export const cardinalityOnly: Rule = (plan) => {
128
130
  if (plan.fromCardinality) return null;
129
131
  if (plan.consumer !== "exists" && plan.consumer !== "none" && plan.consumer !== "count") return null;
130
- if (plan.distinct || plan.orderBy || plan.follow) return null;
132
+ if (plan.distinct || plan.orderBy || plan.follow || plan.groupBy || plan.flatten.length > 0) return null;
131
133
  if (plan.where !== null && plan.residual !== null) return null;
132
134
  return { ...plan, fromCardinality: true };
133
135
  };