@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.
- package/CHANGELOG.md +12 -0
- package/README.md +71 -2
- package/dist/ast.d.ts +53 -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 +9 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +11 -2
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts +4 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +188 -13
- 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 +24 -1
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +8 -1
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +3 -0
- package/dist/optimize/ir.js.map +1 -1
- package/dist/optimize/rules.d.ts.map +1 -1
- package/dist/optimize/rules.js +5 -3
- package/dist/optimize/rules.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +467 -22
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +25 -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 +104 -4
- package/dist/references.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +27 -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 +15 -0
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +63 -2
- package/src/build.ts +13 -4
- package/src/engine.ts +186 -13
- package/src/index.ts +1 -1
- package/src/optimize/analysis.ts +18 -1
- package/src/optimize/ir.ts +11 -1
- package/src/optimize/rules.ts +5 -3
- package/src/parser.ts +387 -22
- package/src/print.ts +27 -3
- package/src/references.ts +95 -8
- package/src/resolve.ts +29 -2
- package/src/version.ts +1 -1
- 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
|
|
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
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
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
|
|
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";
|
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,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
|
-
|
|
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
|
}
|
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, 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,
|
package/src/optimize/rules.ts
CHANGED
|
@@ -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`,
|
|
116
|
-
//
|
|
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
|
};
|