@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.
- package/CHANGELOG.md +6 -0
- package/README.md +42 -2
- package/dist/ast.d.ts +35 -1
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +7 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +7 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +9 -2
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts +2 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +136 -9
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +15 -1
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +5 -1
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +2 -0
- package/dist/optimize/ir.js.map +1 -1
- package/dist/optimize/rules.d.ts.map +1 -1
- package/dist/optimize/rules.js +4 -3
- package/dist/optimize/rules.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +402 -20
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +16 -0
- package/dist/print.js.map +1 -1
- package/dist/references.d.ts +6 -4
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +65 -3
- package/dist/references.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +20 -1
- package/dist/resolve.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts +1 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +10 -0
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +44 -2
- package/src/build.ts +11 -4
- package/src/engine.ts +136 -9
- package/src/index.ts +1 -1
- package/src/optimize/analysis.ts +12 -1
- package/src/optimize/ir.ts +7 -1
- package/src/optimize/rules.ts +4 -3
- package/src/parser.ts +324 -20
- package/src/print.ts +18 -3
- package/src/references.ts +62 -7
- package/src/resolve.ts +22 -2
- package/src/version.ts +1 -1
- 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
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
|
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";
|
package/src/optimize/analysis.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/optimize/ir.ts
CHANGED
|
@@ -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,
|
package/src/optimize/rules.ts
CHANGED
|
@@ -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`,
|
|
116
|
-
//
|
|
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
|
};
|