@omgbase/oqx 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +42 -2
  3. package/dist/ast.d.ts +35 -1
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +7 -1
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +7 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +9 -2
  10. package/dist/build.js.map +1 -1
  11. package/dist/engine.d.ts +2 -0
  12. package/dist/engine.d.ts.map +1 -1
  13. package/dist/engine.js +136 -9
  14. package/dist/engine.js.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/optimize/analysis.d.ts.map +1 -1
  20. package/dist/optimize/analysis.js +15 -1
  21. package/dist/optimize/analysis.js.map +1 -1
  22. package/dist/optimize/ir.d.ts +5 -1
  23. package/dist/optimize/ir.d.ts.map +1 -1
  24. package/dist/optimize/ir.js +2 -0
  25. package/dist/optimize/ir.js.map +1 -1
  26. package/dist/optimize/rules.d.ts.map +1 -1
  27. package/dist/optimize/rules.js +4 -3
  28. package/dist/optimize/rules.js.map +1 -1
  29. package/dist/parser.d.ts.map +1 -1
  30. package/dist/parser.js +402 -20
  31. package/dist/parser.js.map +1 -1
  32. package/dist/print.d.ts.map +1 -1
  33. package/dist/print.js +16 -0
  34. package/dist/print.js.map +1 -1
  35. package/dist/references.d.ts +6 -4
  36. package/dist/references.d.ts.map +1 -1
  37. package/dist/references.js +65 -3
  38. package/dist/references.js.map +1 -1
  39. package/dist/resolve.d.ts.map +1 -1
  40. package/dist/resolve.js +20 -1
  41. package/dist/resolve.js.map +1 -1
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/dist/walk.d.ts +1 -1
  45. package/dist/walk.d.ts.map +1 -1
  46. package/dist/walk.js +10 -0
  47. package/dist/walk.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/ast.ts +44 -2
  50. package/src/build.ts +11 -4
  51. package/src/engine.ts +136 -9
  52. package/src/index.ts +1 -1
  53. package/src/optimize/analysis.ts +12 -1
  54. package/src/optimize/ir.ts +7 -1
  55. package/src/optimize/rules.ts +4 -3
  56. package/src/parser.ts +324 -20
  57. package/src/print.ts +18 -3
  58. package/src/references.ts +62 -7
  59. package/src/resolve.ts +22 -2
  60. package/src/version.ts +1 -1
  61. package/src/walk.ts +12 -1
package/src/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,14 @@ 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">;
125
142
 
126
143
  // The `$key: expr` item of a projection, if any — the identity the rows it
127
144
  // yields carry (SEMANTICS §16, since 0.20).
@@ -172,10 +189,11 @@ export class InMemoryEngine implements Engine {
172
189
  if (query.follow) return this.runFollow(query, rows, root, bound);
173
190
 
174
191
  // 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")) {
192
+ // materialize + dedup by projection before reducing, and under `group by`,
193
+ // whose consumer counts groups). Counting never materializes rows;
194
+ // exists/none stop as soon as the bound is known to be non-empty (the
195
+ // (offset+1)th match — or the first, when unbounded).
196
+ if (!query.distinct && !query.groupBy && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
179
197
  const need = query.consumer === "count" ? Infinity : bound.offset + 1;
180
198
  let n = 0;
181
199
  for (const r of rows) {
@@ -190,7 +208,7 @@ export class InMemoryEngine implements Engine {
190
208
  // first over an unordered, non-distinct set needs only the rows up to the
191
209
  // bound (offset + 1). single materializes everything so its error can
192
210
  // report how many rows actually matched.
193
- const cap = query.consumer === "first" && !query.orderBy && !query.distinct
211
+ const cap = query.consumer === "first" && !query.orderBy && !query.distinct && !query.groupBy
194
212
  ? bound.offset + Math.min(1, bound.limit ?? 1)
195
213
  : Infinity;
196
214
  let kept: Scope[] = [];
@@ -201,6 +219,7 @@ export class InMemoryEngine implements Engine {
201
219
  if (kept.length >= cap) break;
202
220
  }
203
221
  }
222
+ if (query.groupBy) kept = this.groupScopes(kept, query, root);
204
223
  this.applyKey(kept, query);
205
224
  this.sortScopes(kept, query.orderBy);
206
225
  if (query.distinct) kept = this.dedupByProjection(kept, query);
@@ -303,12 +322,101 @@ export class InMemoryEngine implements Engine {
303
322
  if (extra.lifts) s.lifts = extra.lifts;
304
323
  if (extra.meta) s.meta = extra.meta;
305
324
  if (isEntry(row)) {
325
+ s.raw = row;
306
326
  s.row = row.value;
307
327
  s.meta = { ...(s.meta ?? {}), [KEY]: row.key };
308
328
  }
309
329
  return s;
310
330
  }
311
331
 
332
+ // ---- grouping (SEMANTICS §24) ----------------------------------------------
333
+
334
+ // The kept scopes of a body, one GROUP scope per distinct key tuple, in order
335
+ // of first appearance; then `having` over each group. A group scope sits where
336
+ // the rows did (same parent, same depth): its row is the key record — one
337
+ // property per key, in key order, holding the first row's value — so bare
338
+ // names read the keys, `$it` is the record and `$key` its structural identity;
339
+ // `rows` yields the group's rows and the aggregates reduce their scopes. Key
340
+ // values compare as identities (§16): absent ≡ null, a host row by its id,
341
+ // anything else structurally.
342
+ private groupScopes(scopes: Scope[], body: Grouping, parent: Scope): Scope[] {
343
+ const g = body.groupBy!;
344
+ const groups = new Map<string, Scope>();
345
+ for (const s of scopes) {
346
+ const record: Record<string, unknown> = {};
347
+ const ids: string[] = [];
348
+ for (const k of g.keys) {
349
+ const v = this.evalExpr(k.expr, s);
350
+ record[k.name] = v;
351
+ ids.push(canonicalKey(this.hostIdentity(v)));
352
+ }
353
+ const id = ids.join("");
354
+ let gs = groups.get(id);
355
+ if (!gs) {
356
+ gs = { row: record, parent, bindings: parent.bindings, run: parent.run, lifts: {}, group: { rowScopes: [], rows: [] } };
357
+ groups.set(id, gs);
358
+ }
359
+ gs.group!.rowScopes.push(s);
360
+ gs.group!.rows.push(s.raw !== undefined ? s.raw : s.row);
361
+ }
362
+ const out = [...groups.values()];
363
+ return body.having ? out.filter((gs) => this.evalWhere(body.having!, gs)) : out;
364
+ }
365
+
366
+ // An aggregate (§24) over the group of the scope it is read in. The argument
367
+ // is read on each row's scope in group order; every value aggregate sees the
368
+ // PRESENT values only (`count()` alone counts rows), `distinct` dedups them by
369
+ // identity (§16), and `any`/`all` test truthiness (absent is false) and stop
370
+ // at the first row that decides, like `||` / `&&`.
371
+ private aggregate(e: Extract<Expr, { kind: "aggregate" }>, scope: Scope): unknown {
372
+ const g = scope.group;
373
+ 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");
374
+ if (e.arg === null) return g.rowScopes.length;
375
+ const arg = e.arg;
376
+ if (e.name === "any") return g.rowScopes.some((s) => truthy(this.evalExpr(arg, s)));
377
+ if (e.name === "all") return g.rowScopes.every((s) => truthy(this.evalExpr(arg, s)));
378
+ let values: unknown[] = [];
379
+ for (const s of g.rowScopes) {
380
+ const v = this.evalExpr(arg, s);
381
+ if (v != null) values.push(v);
382
+ }
383
+ if (e.distinct) {
384
+ const seen = new Set<string>();
385
+ values = values.filter((v) => {
386
+ const k = canonicalKey(this.hostIdentity(v));
387
+ if (seen.has(k)) return false;
388
+ seen.add(k);
389
+ return true;
390
+ });
391
+ }
392
+ switch (e.name) {
393
+ case "count": return values.length;
394
+ case "collect": return values;
395
+ case "first": return values.length > 0 ? values[0] : undefined;
396
+ case "last": return values.length > 0 ? values[values.length - 1] : undefined;
397
+ case "sum": case "avg": {
398
+ let total = 0;
399
+ for (const v of values) {
400
+ if (typeof v !== "number") {
401
+ throw new OqxError(`cannot ${e.name === "sum" ? "sum" : "average"} \`${describe(arg)}\` over the group: it is ${describeValueKind(v)} — \`${e.name}\` takes numbers`, "eval");
402
+ }
403
+ total += v;
404
+ }
405
+ if (e.name === "sum") return total;
406
+ return values.length === 0 ? undefined : total / values.length;
407
+ }
408
+ case "min": case "max": {
409
+ if (values.length === 0) return undefined;
410
+ let best = values[0];
411
+ for (let i = 1; i < values.length; i++) {
412
+ const c = compare(values[i], best);
413
+ if (c !== undefined && (e.name === "min" ? c < 0 : c > 0)) best = values[i];
414
+ }
415
+ return best;
416
+ }
417
+ }
418
+ }
419
+
312
420
  // ---- follow ---------------------------------------------------------------
313
421
 
314
422
  private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
@@ -317,6 +425,7 @@ export class InMemoryEngine implements Engine {
317
425
  const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
318
426
  let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
319
427
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
428
+ if (query.groupBy) scopes = this.groupScopes(scopes, query, root);
320
429
  this.sortScopes(scopes, query.orderBy);
321
430
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
322
431
  scopes = sliceBound(scopes, bound);
@@ -540,9 +649,10 @@ export class InMemoryEngine implements Engine {
540
649
  // rows, and the consumer only needs their count after the bound.
541
650
  n = boundedCount(acc.rows.length, bound);
542
651
  scope.run.trace?.({ kind: "cardinality", count: n });
543
- } else if (op.op !== "count" && bound === UNBOUNDED) {
652
+ } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy) {
544
653
  // Unbounded exists/none: stop at the first match (dedup cannot change
545
- // emptiness). Bounded: the offset/limit decide emptiness, so materialize.
654
+ // emptiness). Bounded, or grouped (`having` may empty the groups): the
655
+ // offset/limit decide emptiness, so materialize.
546
656
  n = this.anyMatch(op, scope, acc) ? 1 : 0;
547
657
  } else n = this.opRows(op, scope, bound, acc).length;
548
658
  if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
@@ -567,9 +677,10 @@ export class InMemoryEngine implements Engine {
567
677
  return this.opRows(op, scope, bound).length > 0;
568
678
  }
569
679
 
570
- // The rows a consumer op reduces: matched → ordered → distinct → bounded.
680
+ // The rows a consumer op reduces: matched → grouped → ordered → distinct → bounded.
571
681
  private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
572
682
  let scopes = this.matchRows(op, scope, acc);
683
+ if (op.sub.groupBy) scopes = this.groupScopes(scopes, op.sub, scope);
573
684
  this.applyKey(scopes, op.sub);
574
685
  this.sortScopes(scopes, op.sub.orderBy);
575
686
  if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
@@ -758,6 +869,8 @@ export class InMemoryEngine implements Engine {
758
869
  // Over a single receiver (one object) a `collect` is the record or absent,
759
870
  // not a one-element list (SEMANTICS §3).
760
871
  case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
872
+ // `count { … }` as a value (0.24): the number of rows the block yields.
873
+ case "count": return scopes.length;
761
874
  default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : null;
762
875
  }
763
876
  }
@@ -778,6 +891,7 @@ export class InMemoryEngine implements Engine {
778
891
  const { rows, where } = this.access(op, plan, scope);
779
892
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
780
893
  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 }));
894
+ if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
781
895
  this.sortScopes(scopes, sub.orderBy);
782
896
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
783
897
  scopes = sliceBound(scopes, bound);
@@ -787,7 +901,7 @@ export class InMemoryEngine implements Engine {
787
901
  scopes = this.opRows(op, scope, bound, acc);
788
902
  }
789
903
  switch (op.op) {
790
- case "collect": return { scopes, single };
904
+ case "collect": case "count": return { scopes, single };
791
905
  case "first": return { scopes: scopes.slice(0, 1), single };
792
906
  case "single":
793
907
  if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
@@ -934,6 +1048,8 @@ export class InMemoryEngine implements Engine {
934
1048
  }
935
1049
  return absent ? undefined : out;
936
1050
  }
1051
+ // An aggregate (SEMANTICS §24) over the group of the current scope.
1052
+ case "aggregate": return this.aggregate(e, scope);
937
1053
  }
938
1054
  }
939
1055
 
@@ -965,6 +1081,8 @@ export class InMemoryEngine implements Engine {
965
1081
  if (name === "$it") return scope.row;
966
1082
  // `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
967
1083
  if (name === KEY) return this.scopeIdentity(scope);
1084
+ // `rows` on a group scope is the group's rows (§24); elsewhere a property.
1085
+ if (name === ROWS && scope.group) return scope.group.rows;
968
1086
  if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
969
1087
  if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
970
1088
  if (scope.parent === null) return this.ctx.root(name);
@@ -1090,6 +1208,14 @@ function describe(e: Expr): string {
1090
1208
  return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
1091
1209
  }
1092
1210
 
1211
+ // The kind of a value, for an aggregate's error: `a string ("abc")`, `a list`, …
1212
+ function describeValueKind(v: unknown): string {
1213
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return `a ${typeof v} (${JSON.stringify(v)})`;
1214
+ if (isRange(v)) return "a range";
1215
+ if (Array.isArray(v) || (typeof v === "object" && v !== null && !isEntry(v) && Symbol.iterator in v)) return "a list";
1216
+ return "a record";
1217
+ }
1218
+
1093
1219
  function describeReceiver(e: Expr): string {
1094
1220
  if (e.kind === "ident") return e.name;
1095
1221
  if (e.kind === "member") return `${describeReceiver(e.recv)}.${e.name}`;
@@ -1130,6 +1256,7 @@ function exprHasRecur(e: Expr): boolean {
1130
1256
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1131
1257
  case "tuple": return e.items.some(exprHasRecur);
1132
1258
  case "template": return e.parts.some((p) => typeof p !== "string" && exprHasRecur(p));
1259
+ case "aggregate": return e.arg !== null && exprHasRecur(e.arg);
1133
1260
  default: return false;
1134
1261
  }
1135
1262
  }
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,9 @@ 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
+ // `group by` keys are read on the rows, `having` on the groups — both at `at` (0.24).
76
+ if (sub.groupBy?.keys.some((k) => exprReadsScopeIn(k.expr, at, upTo))) return true;
77
+ if (sub.having && whereReadsScopeIn(sub.having, at, upTo)) return true;
73
78
  return sub.follow !== null && followReadsScopeIn(sub.follow, at, upTo);
74
79
  }
75
80
 
@@ -109,6 +114,9 @@ export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
109
114
  // A template literal raises on a list or record interpolation (§7b), which
110
115
  // only the data decides: with an interpolation it is never raise-free.
111
116
  case "template": return e.parts.every((p) => typeof p === "string");
117
+ // `sum`/`avg` raise on a non-number (§24), which only the data decides;
118
+ // and an aggregate outside a group scope raises.
119
+ case "aggregate": return false;
112
120
  }
113
121
  }
114
122
 
@@ -133,7 +141,9 @@ export function whereRaiseFree(w: Where, ctx: RuleContext): boolean {
133
141
  export function opRaiseFree(op: OpNode, ctx: RuleContext): boolean {
134
142
  if (op.op !== "exists" && op.op !== "none" && op.op !== "count") return false;
135
143
  const sub = op.sub;
136
- if (sub.follow || sub.limit || sub.offset || op.distinct) return false;
144
+ // A grouped block evaluates its keys, `having` and the aggregates of its
145
+ // projection per group (a `sum` over a string raises): never raise-free.
146
+ if (sub.follow || sub.limit || sub.offset || op.distinct || sub.groupBy) return false;
137
147
  if (!exprRaiseFree(op.receiver, ctx)) return false;
138
148
  if (!sub.from.every((e) => exprRaiseFree(e, ctx))) return false;
139
149
  if (sub.where && !whereRaiseFree(sub.where, ctx)) return false;
@@ -152,6 +162,7 @@ export function opHasLifts(op: OpNode): boolean {
152
162
  function subHasLifts(sub: Subquery): boolean {
153
163
  if (sub.select.some((item) => (item.kind === "field" ? item.lift > 0 : opHasLifts(item.op)))) return true;
154
164
  if (sub.where && whereHasLifts(sub.where)) return true;
165
+ if (sub.having && whereHasLifts(sub.having)) return true;
155
166
  if (sub.follow?.destinations.some((d) => d.kind === "op" && opHasLifts(d))) return true;
156
167
  return false;
157
168
  }
@@ -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, 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,10 @@ 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
+ /** `group by` / `having` (0.24): the matched rows are grouped before the
49
+ * consumer sees them, so a consumer counts GROUPS, never rows. */
50
+ readonly groupBy: GroupBy | null;
51
+ readonly having: Where | null;
48
52
  /** Equalities answered by an index probe on the receiver (in conjunct order). */
49
53
  readonly correlated: readonly Correlation[];
50
54
  /** The conjuncts still evaluated per row once the probe has selected its
@@ -90,6 +94,8 @@ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
90
94
  distinct: node.distinct,
91
95
  bound: { limit: sub.limit, offset: sub.offset },
92
96
  follow: sub.follow,
97
+ groupBy: sub.groupBy,
98
+ having: sub.having,
93
99
  correlated: [],
94
100
  residual: sub.where,
95
101
  receiverStable: false,
@@ -112,8 +112,9 @@ 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), and with nothing left to evaluate per row: either the block has no
117
+ // `where`, or every conjunct is correlated (so the probe's bucket is the
117
118
  // matched set).
118
119
  //
119
120
  // Argument. With no residual predicate the matched rows are exactly the
@@ -127,7 +128,7 @@ export const invariantBlock: Rule = (plan) => {
127
128
  export const cardinalityOnly: Rule = (plan) => {
128
129
  if (plan.fromCardinality) return null;
129
130
  if (plan.consumer !== "exists" && plan.consumer !== "none" && plan.consumer !== "count") return null;
130
- if (plan.distinct || plan.orderBy || plan.follow) return null;
131
+ if (plan.distinct || plan.orderBy || plan.follow || plan.groupBy) return null;
131
132
  if (plan.where !== null && plan.residual !== null) return null;
132
133
  return { ...plan, fromCardinality: true };
133
134
  };