@omgbase/oqx 0.13.0 → 0.14.1
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 +94 -0
- package/README.md +184 -17
- package/dist/adapters/indexed.d.ts +13 -1
- package/dist/adapters/indexed.d.ts.map +1 -1
- package/dist/adapters/indexed.js +55 -24
- package/dist/adapters/indexed.js.map +1 -1
- package/dist/ast.d.ts +5 -1
- package/dist/ast.d.ts.map +1 -1
- package/dist/context.d.ts +24 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js.map +1 -1
- package/dist/engine.d.ts +55 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +295 -37
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/optimize/analysis.d.ts +45 -0
- package/dist/optimize/analysis.d.ts.map +1 -0
- package/dist/optimize/analysis.js +194 -0
- package/dist/optimize/analysis.js.map +1 -0
- package/dist/optimize/driver.d.ts +8 -0
- package/dist/optimize/driver.d.ts.map +1 -0
- package/dist/optimize/driver.js +35 -0
- package/dist/optimize/driver.js.map +1 -0
- package/dist/optimize/hash-index.d.ts +34 -0
- package/dist/optimize/hash-index.d.ts.map +1 -0
- package/dist/optimize/hash-index.js +79 -0
- package/dist/optimize/hash-index.js.map +1 -0
- package/dist/optimize/index.d.ts +8 -0
- package/dist/optimize/index.d.ts.map +1 -0
- package/dist/optimize/index.js +11 -0
- package/dist/optimize/index.js.map +1 -0
- package/dist/optimize/ir.d.ts +84 -0
- package/dist/optimize/ir.d.ts.map +1 -0
- package/dist/optimize/ir.js +60 -0
- package/dist/optimize/ir.js.map +1 -0
- package/dist/optimize/rules.d.ts +8 -0
- package/dist/optimize/rules.d.ts.map +1 -0
- package/dist/optimize/rules.js +136 -0
- package/dist/optimize/rules.js.map +1 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +59 -5
- package/dist/parser.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/indexed.ts +60 -24
- package/src/ast.ts +6 -1
- package/src/context.ts +24 -0
- package/src/engine.ts +329 -35
- package/src/index.ts +7 -2
- package/src/optimize/analysis.ts +183 -0
- package/src/optimize/driver.ts +37 -0
- package/src/optimize/hash-index.ts +91 -0
- package/src/optimize/index.ts +16 -0
- package/src/optimize/ir.ts +128 -0
- package/src/optimize/rules.ts +136 -0
- package/src/parser.ts +54 -6
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Static (syntactic) analyses the rules rest on. Each answers one question
|
|
2
|
+
// about an AST fragment without evaluating anything:
|
|
3
|
+
//
|
|
4
|
+
// • which scopes does it READ (`readsScopeIn`) — by depth, since a bare name
|
|
5
|
+
// reads the current scope and `^name` reads exactly `levels` scopes out
|
|
6
|
+
// (SEMANTICS §2), so an expression evaluated at a known depth reads a
|
|
7
|
+
// known set of depths;
|
|
8
|
+
// • can evaluating it RAISE (`raiseFree`) — the eval errors SEMANTICS §23
|
|
9
|
+
// enumerates: a call (unknown name, bad regex, wrong receiver), `single`
|
|
10
|
+
// over several rows, an invalid `limit`/`offset`, `follow` in a where op, a
|
|
11
|
+
// range in a result, a binding out of range. Property reads are total
|
|
12
|
+
// (navigating from absent is absent); a `DataContext.get` that throws is
|
|
13
|
+
// outside this model and is caught at the points the engine relies on it
|
|
14
|
+
// (index construction, probe-value evaluation), where it degrades to a scan;
|
|
15
|
+
// • does it have SIDE EFFECTS on scopes (`hasLifts`) — a `^name:` item binds a
|
|
16
|
+
// value into an enclosing scope (§19), observable by later conjuncts.
|
|
17
|
+
|
|
18
|
+
import type { Expr, Follow, OpNode, SelectItem, Subquery, Where } from "../ast.ts";
|
|
19
|
+
import type { RuleContext } from "./ir.ts";
|
|
20
|
+
|
|
21
|
+
// ---- scope reads --------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Does `e`, evaluated in a scope at depth `at` (root = 0), read any scope whose
|
|
24
|
+
* depth lies in `[1, upTo]`? Reads of the root (named roots, depth 0) and of
|
|
25
|
+
* scopes deeper than `upTo` (the fragment's own rows) do not count. */
|
|
26
|
+
export function exprReadsScopeIn(e: Expr, at: number, upTo: number): boolean {
|
|
27
|
+
switch (e.kind) {
|
|
28
|
+
case "lit": case "binding": return false;
|
|
29
|
+
case "ident": return at >= 1 && at <= upTo;
|
|
30
|
+
case "outer": { const d = at - e.levels; return d >= 1 && d <= upTo; }
|
|
31
|
+
case "member": return exprReadsScopeIn(e.recv, at, upTo);
|
|
32
|
+
case "index": return exprReadsScopeIn(e.recv, at, upTo) || exprReadsScopeIn(e.index, at, upTo);
|
|
33
|
+
case "call": return (e.recv !== null && exprReadsScopeIn(e.recv, at, upTo)) || e.args.some((a) => exprReadsScopeIn(a, at, upTo));
|
|
34
|
+
case "unary": return exprReadsScopeIn(e.expr, at, upTo);
|
|
35
|
+
case "binary": case "logical": case "in": return exprReadsScopeIn(e.left, at, upTo) || exprReadsScopeIn(e.right, at, upTo);
|
|
36
|
+
case "range": return (e.lo !== null && exprReadsScopeIn(e.lo, at, upTo)) || (e.hi !== null && exprReadsScopeIn(e.hi, at, upTo));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function whereReadsScopeIn(w: Where, at: number, upTo: number): boolean {
|
|
41
|
+
switch (w.kind) {
|
|
42
|
+
case "and": case "or": return w.parts.some((p) => whereReadsScopeIn(p, at, upTo));
|
|
43
|
+
case "not": return whereReadsScopeIn(w.expr, at, upTo);
|
|
44
|
+
case "scalar": return exprReadsScopeIn(w.expr, at, upTo);
|
|
45
|
+
case "op": return opReadsScopeIn(w, at, upTo);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A directive evaluated in a scope at depth `at`: its receiver is read there;
|
|
50
|
+
* its rows are scopes at `at + 1`. */
|
|
51
|
+
export function opReadsScopeIn(op: OpNode, at: number, upTo: number): boolean {
|
|
52
|
+
return exprReadsScopeIn(op.receiver, at, upTo) || subReadsScopeIn(op.sub, at + 1, upTo);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A block body whose rows are scopes at depth `at`. `from` re-projections,
|
|
56
|
+
* `where`, `select`, `order by` and the bound are read in the row scope (the
|
|
57
|
+
* bound in a row-less scope at the same depth); `follow` walks occurrences at
|
|
58
|
+
* `at`, with its successor `where` one deeper (§20). */
|
|
59
|
+
function subReadsScopeIn(sub: Subquery, at: number, upTo: number): boolean {
|
|
60
|
+
if (sub.from.some((e) => exprReadsScopeIn(e, at, upTo))) return true;
|
|
61
|
+
if (sub.where && whereReadsScopeIn(sub.where, at, upTo)) return true;
|
|
62
|
+
if (sub.select.some((item) => itemReadsScopeIn(item, at, upTo))) return true;
|
|
63
|
+
if (sub.orderBy?.some((o) => exprReadsScopeIn(o.expr, at, upTo))) return true;
|
|
64
|
+
if (sub.limit && exprReadsScopeIn(sub.limit, at, upTo)) return true;
|
|
65
|
+
if (sub.offset && exprReadsScopeIn(sub.offset, at, upTo)) return true;
|
|
66
|
+
return sub.follow !== null && followReadsScopeIn(sub.follow, at, upTo);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function itemReadsScopeIn(item: SelectItem, at: number, upTo: number): boolean {
|
|
70
|
+
return item.kind === "field" ? exprReadsScopeIn(item.expr, at, upTo) : opReadsScopeIn(item.op, at, upTo);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function followReadsScopeIn(f: Follow, at: number, upTo: number): boolean {
|
|
74
|
+
for (const d of f.destinations) {
|
|
75
|
+
if (d.kind === "op" ? opReadsScopeIn(d, at, upTo) : exprReadsScopeIn(d, at, upTo)) return true;
|
|
76
|
+
}
|
|
77
|
+
if (f.where && exprReadsScopeIn(f.where, at + 1, upTo)) return true;
|
|
78
|
+
if (f.frontier && exprReadsScopeIn(f.frontier, at, upTo)) return true;
|
|
79
|
+
return f.by !== null && exprReadsScopeIn(f.by, at, upTo);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Does `e` read the scope it is evaluated in (a bare identifier anywhere)? */
|
|
83
|
+
export function exprReadsCurrentScope(e: Expr): boolean {
|
|
84
|
+
return exprReadsScopeIn(e, 1, 1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---- raise freedom ------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** Can evaluating `e` raise? Calls can (unknown function/method, regex errors,
|
|
90
|
+
* a context's own failures); a binding at or past the run's count does; nothing
|
|
91
|
+
* else in a scalar expression can (§23). */
|
|
92
|
+
export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
|
|
93
|
+
switch (e.kind) {
|
|
94
|
+
case "lit": case "ident": case "outer": return true;
|
|
95
|
+
case "binding": return e.index < ctx.bindingCount;
|
|
96
|
+
case "call": return false;
|
|
97
|
+
case "member": return exprRaiseFree(e.recv, ctx);
|
|
98
|
+
case "index": return exprRaiseFree(e.recv, ctx) && exprRaiseFree(e.index, ctx);
|
|
99
|
+
case "unary": return exprRaiseFree(e.expr, ctx);
|
|
100
|
+
case "binary": case "logical": case "in": return exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
|
|
101
|
+
case "range": return (e.lo === null || exprRaiseFree(e.lo, ctx)) && (e.hi === null || exprRaiseFree(e.hi, ctx));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Can evaluating `w` raise, or bind a lift? A where tree is raise-free when
|
|
106
|
+
* its scalars are and its directives are raise-free (`opRaiseFree`). */
|
|
107
|
+
export function whereRaiseFree(w: Where, ctx: RuleContext): boolean {
|
|
108
|
+
switch (w.kind) {
|
|
109
|
+
case "and": case "or": return w.parts.every((p) => whereRaiseFree(p, ctx));
|
|
110
|
+
case "not": return whereRaiseFree(w.expr, ctx);
|
|
111
|
+
case "scalar": return exprRaiseFree(w.expr, ctx);
|
|
112
|
+
case "op": return opRaiseFree(w, ctx);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A where-position directive that can neither raise nor bind: `exists`,
|
|
117
|
+
* `none` or `count` (never `single`, which raises on several rows; `collect`
|
|
118
|
+
* binds lifts; `first`/`single` project), without `follow` (raises in where
|
|
119
|
+
* position), without a bound (an invalid value raises), without `distinct`
|
|
120
|
+
* (dedup projects, and a projected range raises), whose receiver, `from`,
|
|
121
|
+
* `where` and `order by` (evaluated by `count`) are raise-free and which lifts
|
|
122
|
+
* nothing anywhere. */
|
|
123
|
+
export function opRaiseFree(op: OpNode, ctx: RuleContext): boolean {
|
|
124
|
+
if (op.op !== "exists" && op.op !== "none" && op.op !== "count") return false;
|
|
125
|
+
const sub = op.sub;
|
|
126
|
+
if (sub.follow || sub.limit || sub.offset || op.distinct) return false;
|
|
127
|
+
if (!exprRaiseFree(op.receiver, ctx)) return false;
|
|
128
|
+
if (!sub.from.every((e) => exprRaiseFree(e, ctx))) return false;
|
|
129
|
+
if (sub.where && !whereRaiseFree(sub.where, ctx)) return false;
|
|
130
|
+
if (sub.orderBy && !sub.orderBy.every((o) => exprRaiseFree(o.expr, ctx))) return false;
|
|
131
|
+
return !opHasLifts(op);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- lifts --------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** Does any `^name:` item occur anywhere inside the directive (its own select,
|
|
137
|
+
* or any nested block's)? */
|
|
138
|
+
export function opHasLifts(op: OpNode): boolean {
|
|
139
|
+
return subHasLifts(op.sub);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function subHasLifts(sub: Subquery): boolean {
|
|
143
|
+
if (sub.select.some((item) => (item.kind === "field" ? item.lift > 0 : opHasLifts(item.op)))) return true;
|
|
144
|
+
if (sub.where && whereHasLifts(sub.where)) return true;
|
|
145
|
+
if (sub.follow?.destinations.some((d) => d.kind === "op" && opHasLifts(d))) return true;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Does any `^name:` item occur inside a directive of this where tree? */
|
|
150
|
+
export function whereHasLifts(w: Where): boolean {
|
|
151
|
+
switch (w.kind) {
|
|
152
|
+
case "and": case "or": return w.parts.some(whereHasLifts);
|
|
153
|
+
case "not": return whereHasLifts(w.expr);
|
|
154
|
+
case "scalar": return false;
|
|
155
|
+
case "op": return opHasLifts(w);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- local paths --------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/** The scope-metadata intrinsics: `$key` (an entry's key) and the `follow`
|
|
162
|
+
* occurrence fields. In a block's row scope these read scope metadata when it is
|
|
163
|
+
* present and fall through to the context otherwise; every other `$`-name
|
|
164
|
+
* (`$id`, `$path`, a context's own intrinsics) is always a plain `get` on the
|
|
165
|
+
* row. */
|
|
166
|
+
export const SCOPE_INTRINSICS: ReadonlySet<string> = new Set(["$key", "$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
167
|
+
|
|
168
|
+
/** The property path a bare identifier or member chain reads off the current
|
|
169
|
+
* row — `customer_id` → `["customer_id"]`, `meta.id` → `["meta", "id"]`,
|
|
170
|
+
* `$value` → `[]`, `$value.x` → `["x"]`, `$path` → `["$path"]` — or `null` when
|
|
171
|
+
* `e` is not such a chain or starts from a scope intrinsic (`$key`, `$depth`,
|
|
172
|
+
* …, whose value is scope metadata rather than a row property). */
|
|
173
|
+
export function localPath(e: Expr): readonly string[] | null {
|
|
174
|
+
if (e.kind === "ident") {
|
|
175
|
+
if (e.name === "$value") return [];
|
|
176
|
+
return SCOPE_INTRINSICS.has(e.name) ? null : [e.name];
|
|
177
|
+
}
|
|
178
|
+
if (e.kind === "member") {
|
|
179
|
+
const head = localPath(e.recv);
|
|
180
|
+
return head === null ? null : [...head, e.name];
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The optimizer driver: applies a rule set to a block's logical plan until no
|
|
2
|
+
// rule changes it (a fixpoint, bounded), and caches the result per AST node —
|
|
3
|
+
// the analysis is syntactic, so a plan depends only on the node, its static
|
|
4
|
+
// depth, the run's binding count and the rule set.
|
|
5
|
+
|
|
6
|
+
import type { OpNode } from "../ast.ts";
|
|
7
|
+
import type { BlockPlan, Rule, RuleContext } from "./ir.ts";
|
|
8
|
+
import { logicalBlock } from "./ir.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_PASSES = 8;
|
|
11
|
+
|
|
12
|
+
/** Rewrite `logicalBlock(node, depth)` to a fixpoint of `rules`. */
|
|
13
|
+
export function optimizeBlock(node: OpNode, depth: number, ctx: RuleContext, rules: readonly Rule[]): BlockPlan {
|
|
14
|
+
let plan = logicalBlock(node, depth);
|
|
15
|
+
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
16
|
+
let changed = false;
|
|
17
|
+
for (const rule of rules) {
|
|
18
|
+
const next = rule(plan, ctx);
|
|
19
|
+
if (next !== null && next !== plan) { plan = next; changed = true; }
|
|
20
|
+
}
|
|
21
|
+
if (!changed) break;
|
|
22
|
+
}
|
|
23
|
+
return plan;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Cached { depth: number; bindingCount: number; rules: readonly Rule[]; plan: BlockPlan }
|
|
27
|
+
const cache = new WeakMap<OpNode, Cached>();
|
|
28
|
+
|
|
29
|
+
/** `optimizeBlock`, memoized on the node (re-planned when depth, binding count
|
|
30
|
+
* or rule set differ from the cached entry). */
|
|
31
|
+
export function planFor(node: OpNode, depth: number, ctx: RuleContext, rules: readonly Rule[]): BlockPlan {
|
|
32
|
+
const hit = cache.get(node);
|
|
33
|
+
if (hit && hit.depth === depth && hit.bindingCount === ctx.bindingCount && hit.rules === rules) return hit.plan;
|
|
34
|
+
const plan = optimizeBlock(node, depth, ctx, rules);
|
|
35
|
+
cache.set(node, { depth, bindingCount: ctx.bindingCount, rules, plan });
|
|
36
|
+
return plan;
|
|
37
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// A hash index over the rows of one collection, keyed under OQX equality
|
|
2
|
+
// (SEMANTICS §5) — the only key discipline that makes an index probe return
|
|
3
|
+
// exactly the rows a `local == value` scan would keep:
|
|
4
|
+
//
|
|
5
|
+
// • absent is one value: `undefined` and `null` share a bucket;
|
|
6
|
+
// • numbers are doubles: `-0` meets `0`; `NaN` equals nothing, so a row whose
|
|
7
|
+
// key is `NaN` is never indexed and a probe for `NaN` finds nothing;
|
|
8
|
+
// • strings, booleans and bigints key by value with a type tag, so `"5"`,
|
|
9
|
+
// `5` and `true` stay apart;
|
|
10
|
+
// • objects, arrays, functions and symbols key by REFERENCE — the reference
|
|
11
|
+
// engine's `===` — never structurally.
|
|
12
|
+
//
|
|
13
|
+
// Buckets hold row POSITIONS in ascending order, so a probe yields rows in the
|
|
14
|
+
// receiver's order (§3 and §13 — a block's rows are the receiver's, in order).
|
|
15
|
+
|
|
16
|
+
/** A pre-built equality index a `DataContext` may expose for a collection
|
|
17
|
+
* (see `DataContext.indexFor`): `lookup(value)` returns the ascending positions
|
|
18
|
+
* (into `toRows(collection)`) of the rows whose key equals `value` under §5.
|
|
19
|
+
*
|
|
20
|
+
* `lookupRows`, when present, answers the same probe with the ROWS themselves —
|
|
21
|
+
* exactly the rows `lookup(value)` would select, in receiver order — without
|
|
22
|
+
* the engine ever materializing `toRows(collection)`. A store-backed context
|
|
23
|
+
* implements it over its own indexes (one indexed statement per probe); the
|
|
24
|
+
* engine prefers it for a statically stable receiver, so the collection is
|
|
25
|
+
* never read whole. A throw is the query's error (as a `get` that throws is). */
|
|
26
|
+
export interface RowIndex {
|
|
27
|
+
lookup(value: unknown): readonly number[];
|
|
28
|
+
lookupRows?(value: unknown): Iterable<unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NONE: readonly number[] = Object.freeze([]);
|
|
32
|
+
|
|
33
|
+
/** The engine's own `RowIndex`: `add` every row's key in row order, then probe. */
|
|
34
|
+
export class HashIndex implements RowIndex {
|
|
35
|
+
private prim = new Map<string, number[]>();
|
|
36
|
+
private refs = new Map<unknown, number[]>();
|
|
37
|
+
private count = 0;
|
|
38
|
+
|
|
39
|
+
/** Record that the row at `position` has key `value`. Positions must be
|
|
40
|
+
* added in ascending order (row order). A `NaN` key is not indexed. */
|
|
41
|
+
add(value: unknown, position: number): void {
|
|
42
|
+
const k = primitiveKey(value);
|
|
43
|
+
if (k === null) return; // NaN: equals nothing
|
|
44
|
+
const map = k === undefined ? this.refs : this.prim;
|
|
45
|
+
const key = k === undefined ? value : k;
|
|
46
|
+
let bucket = map.get(key);
|
|
47
|
+
if (!bucket) map.set(key, (bucket = []));
|
|
48
|
+
bucket.push(position);
|
|
49
|
+
this.count++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lookup(value: unknown): readonly number[] {
|
|
53
|
+
const k = primitiveKey(value);
|
|
54
|
+
if (k === null) return NONE;
|
|
55
|
+
return (k === undefined ? this.refs.get(value) : this.prim.get(k)) ?? NONE;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How many rows were indexed (rows with a `NaN` key are not). */
|
|
59
|
+
get size(): number {
|
|
60
|
+
return this.count;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The tagged bucket key of a primitive, `null` for `NaN` (no key: it equals
|
|
65
|
+
* nothing), `undefined` for a reference-keyed value (object, array, function,
|
|
66
|
+
* symbol — compared by identity). Mirrors `semantics.equals`: `===` after
|
|
67
|
+
* normalizing `undefined` to `null`. */
|
|
68
|
+
export function primitiveKey(v: unknown): string | null | undefined {
|
|
69
|
+
switch (typeof v) {
|
|
70
|
+
case "undefined": return "∅"; // absent ≡ absent
|
|
71
|
+
case "object": return v === null ? "∅" : undefined;
|
|
72
|
+
case "number": return Number.isNaN(v) ? null : `n${v}`; // `${-0}` is "0": -0 meets 0
|
|
73
|
+
case "string": return `s${v}`;
|
|
74
|
+
case "boolean": return v ? "t" : "f";
|
|
75
|
+
case "bigint": return `i${v}`;
|
|
76
|
+
default: return undefined; // function, symbol: reference identity
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Intersect two ascending position lists, ascending. */
|
|
81
|
+
export function intersectPositions(a: readonly number[], b: readonly number[]): readonly number[] {
|
|
82
|
+
const out: number[] = [];
|
|
83
|
+
let i = 0, j = 0;
|
|
84
|
+
while (i < a.length && j < b.length) {
|
|
85
|
+
const x = a[i]!, y = b[j]!;
|
|
86
|
+
if (x === y) { out.push(x); i++; j++; }
|
|
87
|
+
else if (x < y) i++;
|
|
88
|
+
else j++;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The optimizer: a logical plan per nested block, rules that rewrite it, a
|
|
2
|
+
// driver that applies them to a fixpoint, and the equality-faithful hash index
|
|
3
|
+
// the hash-probe rule executes with. See `rules.ts` for the catalog and each
|
|
4
|
+
// rule's semantics argument; README "Performance: relational patterns" for the
|
|
5
|
+
// user-facing summary.
|
|
6
|
+
|
|
7
|
+
export type { BlockPlan, Correlation, Rule, RuleContext } from "./ir.ts";
|
|
8
|
+
export { logicalBlock, conjunction, conjuncts, residualWithout, lookupOrder } from "./ir.ts";
|
|
9
|
+
export { optimizeBlock, planFor } from "./driver.ts";
|
|
10
|
+
export { correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly, DEFAULT_RULES } from "./rules.ts";
|
|
11
|
+
export type { RowIndex } from "./hash-index.ts";
|
|
12
|
+
export { HashIndex, primitiveKey, intersectPositions } from "./hash-index.ts";
|
|
13
|
+
export {
|
|
14
|
+
exprReadsScopeIn, whereReadsScopeIn, opReadsScopeIn, exprReadsCurrentScope,
|
|
15
|
+
exprRaiseFree, whereRaiseFree, opRaiseFree, opHasLifts, whereHasLifts, localPath, SCOPE_INTRINSICS,
|
|
16
|
+
} from "./analysis.ts";
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The optimizer's logical representation of one nested block (an `OpNode`):
|
|
2
|
+
// where its rows come from, which predicates correlate them with the enclosing
|
|
3
|
+
// rows, what remains to be checked per row, how they are shaped, and how the
|
|
4
|
+
// consumer reduces them. Rules rewrite a `BlockPlan`; the engine executes it.
|
|
5
|
+
//
|
|
6
|
+
// A plan starts as a faithful transcription of the AST (`logicalBlock`): every
|
|
7
|
+
// predicate residual, nothing correlated, nothing memoized. Rules move work
|
|
8
|
+
// from the per-row path into the per-run or per-probe path only when the
|
|
9
|
+
// semantics argument in `rules.ts` holds; the engine's fallbacks (an index that
|
|
10
|
+
// cannot be built, a receiver that is not stable) always degrade to the plain
|
|
11
|
+
// scan the plan started from.
|
|
12
|
+
|
|
13
|
+
import type { Consumer, Expr, Follow, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
|
|
14
|
+
|
|
15
|
+
/** One hoisted equality `local == outer` from the block's top-level `&&`
|
|
16
|
+
* conjunction: `local` reads only the block's row (an identifier or member
|
|
17
|
+
* chain; `path` is its property path, `[]` for `$value`), `outer` reads nothing
|
|
18
|
+
* from the block's row (outer references, bindings, literals, arithmetic over
|
|
19
|
+
* them) and cannot raise. */
|
|
20
|
+
export interface Correlation {
|
|
21
|
+
readonly local: Expr;
|
|
22
|
+
readonly outer: Expr;
|
|
23
|
+
readonly path: readonly string[];
|
|
24
|
+
/** Position of the equality among `conjuncts(where)`, so a probe that
|
|
25
|
+
* answers ONE correlation (a store-backed `lookupRows`) can put the others
|
|
26
|
+
* back in their place as residual conjuncts. */
|
|
27
|
+
readonly index: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The plan for one block. `receiver`/`from`/`projection`/`orderBy`/`bound`/
|
|
31
|
+
* `consumer`/`follow`/`distinct` transcribe the AST; `correlated`/`residual`
|
|
32
|
+
* split its `where`; `receiverStable`, `invariant` and `fromCardinality` are the
|
|
33
|
+
* annotations rules add. */
|
|
34
|
+
export interface BlockPlan {
|
|
35
|
+
readonly node: OpNode;
|
|
36
|
+
/** Scope depth of the ENCLOSING scope (root = 0): the receiver is read there;
|
|
37
|
+
* the block's rows are scopes at `depth + 1`. */
|
|
38
|
+
readonly depth: number;
|
|
39
|
+
readonly consumer: Consumer;
|
|
40
|
+
readonly receiver: Expr;
|
|
41
|
+
readonly from: readonly Expr[];
|
|
42
|
+
readonly where: Where | null;
|
|
43
|
+
readonly projection: { readonly select: readonly SelectItem[]; readonly values: boolean };
|
|
44
|
+
readonly orderBy: readonly OrderSpec[] | null;
|
|
45
|
+
readonly distinct: boolean;
|
|
46
|
+
readonly bound: { readonly limit: Expr | undefined; readonly offset: Expr | undefined };
|
|
47
|
+
readonly follow: Follow | null;
|
|
48
|
+
/** Equalities answered by an index probe on the receiver (in conjunct order). */
|
|
49
|
+
readonly correlated: readonly Correlation[];
|
|
50
|
+
/** The conjuncts still evaluated per row once the probe has selected its
|
|
51
|
+
* bucket; equals `where` when nothing is correlated. */
|
|
52
|
+
readonly residual: Where | null;
|
|
53
|
+
/** The receiver reads nothing from any enclosing row (only the root scope
|
|
54
|
+
* and bindings), so its rows are the same for every enclosing row and can be
|
|
55
|
+
* materialized and indexed once per run. */
|
|
56
|
+
readonly receiverStable: boolean;
|
|
57
|
+
/** The whole block reads nothing from any enclosing row and has no side
|
|
58
|
+
* effects on one (no lifts): its value is the same for every enclosing row
|
|
59
|
+
* and is computed once per run. */
|
|
60
|
+
readonly invariant: boolean;
|
|
61
|
+
/** `exists`/`none`/`count` may be answered from the cardinality of the
|
|
62
|
+
* accessed rows when no residual predicate remains (nothing per row to
|
|
63
|
+
* evaluate, order, dedup or project). */
|
|
64
|
+
readonly fromCardinality: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a rule may consult besides the plan. */
|
|
68
|
+
export interface RuleContext {
|
|
69
|
+
/** How many positional bindings this run has (a `${i}` with `i` at or past
|
|
70
|
+
* it raises when evaluated, so it is not raise-free). */
|
|
71
|
+
readonly bindingCount: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A rewrite: return an improved plan, or `null` when the rule does not apply
|
|
75
|
+
* (or has already been applied). Rules must be idempotent on their own output. */
|
|
76
|
+
export type Rule = (plan: BlockPlan, ctx: RuleContext) => BlockPlan | null;
|
|
77
|
+
|
|
78
|
+
/** The faithful transcription of a block: a scan with every predicate residual. */
|
|
79
|
+
export function logicalBlock(node: OpNode, depth: number): BlockPlan {
|
|
80
|
+
const sub = node.sub;
|
|
81
|
+
return {
|
|
82
|
+
node,
|
|
83
|
+
depth,
|
|
84
|
+
consumer: node.op,
|
|
85
|
+
receiver: node.receiver,
|
|
86
|
+
from: sub.from,
|
|
87
|
+
where: sub.where,
|
|
88
|
+
projection: { select: sub.select, values: sub.values ?? false },
|
|
89
|
+
orderBy: sub.orderBy,
|
|
90
|
+
distinct: node.distinct ?? false,
|
|
91
|
+
bound: { limit: sub.limit, offset: sub.offset },
|
|
92
|
+
follow: sub.follow,
|
|
93
|
+
correlated: [],
|
|
94
|
+
residual: sub.where,
|
|
95
|
+
receiverStable: false,
|
|
96
|
+
invariant: false,
|
|
97
|
+
fromCardinality: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Rebuild a conjunction from its remaining parts. */
|
|
102
|
+
export function conjunction(parts: readonly Where[]): Where | null {
|
|
103
|
+
return parts.length === 0 ? null : parts.length === 1 ? parts[0]! : { kind: "and", parts: [...parts] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The top-level `&&` conjuncts of a where tree (a non-`and` tree is one). */
|
|
107
|
+
export function conjuncts(where: Where): readonly Where[] {
|
|
108
|
+
return where.kind === "and" ? where.parts : [where];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The residual when exactly ONE correlation is answered by a probe (a context
|
|
112
|
+
* index's `lookupRows`): every other conjunct, the remaining equalities
|
|
113
|
+
* included, stays in its original place. A kept equality is true by
|
|
114
|
+
* construction for the rows the probe selects and, being two reads, can neither
|
|
115
|
+
* raise nor bind, so the scan's strict left-to-right order and outcome hold. */
|
|
116
|
+
export function residualWithout(plan: BlockPlan, answered: Correlation): Where | null {
|
|
117
|
+
return plan.where ? conjunction(conjuncts(plan.where).filter((_, i) => i !== answered.index)) : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The order in which a single-correlation probe tries the plan's correlations:
|
|
121
|
+
* those whose outer side varies with the enclosing row first (a literal selects
|
|
122
|
+
* the same rows for every enclosing row and narrows nothing), each group in
|
|
123
|
+
* conjunct order. */
|
|
124
|
+
export function lookupOrder(plan: BlockPlan): readonly Correlation[] {
|
|
125
|
+
const all = plan.correlated;
|
|
126
|
+
if (all.length < 2) return all;
|
|
127
|
+
return [...all.filter((c) => c.outer.kind !== "lit"), ...all.filter((c) => c.outer.kind === "lit")];
|
|
128
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The rule catalog. Each rule states its precondition and the argument that the
|
|
2
|
+
// rewritten plan is indistinguishable from the scan it replaces — same rows in
|
|
3
|
+
// the same order, same lifts, same error (same message) at the same point, or
|
|
4
|
+
// no error — under SEMANTICS §13's strict left-to-right `&&`.
|
|
5
|
+
|
|
6
|
+
import type { Expr, Where } from "../ast.ts";
|
|
7
|
+
import {
|
|
8
|
+
exprRaiseFree, exprReadsCurrentScope, exprReadsScopeIn, localPath, opHasLifts, opReadsScopeIn,
|
|
9
|
+
whereHasLifts, whereRaiseFree,
|
|
10
|
+
} from "./analysis.ts";
|
|
11
|
+
import type { Correlation, Rule, RuleContext } from "./ir.ts";
|
|
12
|
+
import { conjunction, conjuncts } from "./ir.ts";
|
|
13
|
+
|
|
14
|
+
// ---- rule 1: correlated equality → hash probe ------------------------------------
|
|
15
|
+
//
|
|
16
|
+
// Precondition. The block has no `from` re-projection (its rows ARE the
|
|
17
|
+
// receiver's rows) and no `follow`; its `where` has no lift anywhere (a lift
|
|
18
|
+
// into an enclosing scope by one row could change what the outer side reads
|
|
19
|
+
// for the next row); some top-level conjunct k is `local == outer` (either
|
|
20
|
+
// side) with `local` an identifier/member chain on the block's row and `outer`
|
|
21
|
+
// a raise-free expression reading nothing from the block's row; and every
|
|
22
|
+
// conjunct left of k is raise-free (no call, no `single`, no lift, no bound, no
|
|
23
|
+
// binding past the run's count).
|
|
24
|
+
//
|
|
25
|
+
// Argument. The scan evaluates, per receiver row r in order, c1(r) && … &&
|
|
26
|
+
// ck(r) && …, short-circuiting. The probe selects exactly the rows with
|
|
27
|
+
// local(r) == outer (the index reproduces §5 equality, `outer` is evaluated
|
|
28
|
+
// once because nothing in the block can change what it reads) and evaluates the
|
|
29
|
+
// residual conjunction over them, in receiver order. For a selected row every
|
|
30
|
+
// conjunct is evaluated as the scan would (ck is dropped: it is true by
|
|
31
|
+
// construction and, being two reads, cannot raise or bind). For a row the probe
|
|
32
|
+
// rejects, the scan would have evaluated c1..c(k-1) — which cannot raise or
|
|
33
|
+
// bind — then ck, false, and stopped: no result, no error, no lift. So the two
|
|
34
|
+
// paths keep the same rows, raise the same first error, and bind the same
|
|
35
|
+
// lifts. A probe that cannot be built at run time (a receiver that is not
|
|
36
|
+
// stable, an index whose construction throws) falls back to the scan itself.
|
|
37
|
+
|
|
38
|
+
export const correlatedEqualityProbe: Rule = (plan, ctx) => {
|
|
39
|
+
if (plan.correlated.length > 0 || plan.from.length > 0 || plan.follow || !plan.where) return null;
|
|
40
|
+
if (whereHasLifts(plan.where)) return null;
|
|
41
|
+
const correlated: Correlation[] = [];
|
|
42
|
+
const residual: Where[] = [];
|
|
43
|
+
let prefixRaiseFree = true;
|
|
44
|
+
conjuncts(plan.where).forEach((part, index) => {
|
|
45
|
+
const eq = prefixRaiseFree ? asCorrelation(part, index, ctx) : null;
|
|
46
|
+
if (eq) correlated.push(eq);
|
|
47
|
+
else {
|
|
48
|
+
residual.push(part);
|
|
49
|
+
prefixRaiseFree &&= whereRaiseFree(part, ctx);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (correlated.length === 0) return null;
|
|
53
|
+
return { ...plan, correlated, residual: conjunction(residual) };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** `local == outer` / `outer == local` as a correlation, else null. */
|
|
57
|
+
function asCorrelation(w: Where, index: number, ctx: RuleContext): Correlation | null {
|
|
58
|
+
if (w.kind !== "scalar" || w.expr.kind !== "binary" || w.expr.op !== "==") return null;
|
|
59
|
+
const { left, right } = w.expr;
|
|
60
|
+
return pair(left, right, index, ctx) ?? pair(right, left, index, ctx);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pair(local: Expr, outer: Expr, index: number, ctx: RuleContext): Correlation | null {
|
|
64
|
+
const path = localPath(local);
|
|
65
|
+
if (path === null) return null;
|
|
66
|
+
if (exprReadsCurrentScope(outer) || !exprRaiseFree(outer, ctx)) return null;
|
|
67
|
+
return { local, outer, path, index };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- rule 1b: a receiver stable across enclosing rows ----------------------------
|
|
71
|
+
//
|
|
72
|
+
// Precondition. The receiver expression, read in the enclosing scope (depth
|
|
73
|
+
// `plan.depth`), reads no scope at depth 1..depth: only the root (named roots,
|
|
74
|
+
// `^…^name` reaching it, or past it) and bindings.
|
|
75
|
+
//
|
|
76
|
+
// Argument. Such an expression yields the same value for every enclosing row
|
|
77
|
+
// (evaluation is pure; the root and the bindings do not change within a run),
|
|
78
|
+
// so its rows can be materialized once per run and indexed once per path. A
|
|
79
|
+
// receiver that does read an enclosing row is still indexable when it happens
|
|
80
|
+
// to evaluate to the same object twice (the engine keys indexes by collection
|
|
81
|
+
// identity as well); a receiver that yields a fresh value per row is scanned.
|
|
82
|
+
|
|
83
|
+
export const stableReceiver: Rule = (plan) => {
|
|
84
|
+
if (plan.receiverStable) return null;
|
|
85
|
+
if (exprReadsScopeIn(plan.receiver, plan.depth, plan.depth)) return null;
|
|
86
|
+
return { ...plan, receiverStable: true };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ---- rule 2: invariant nested block → evaluate once per run ----------------------
|
|
90
|
+
//
|
|
91
|
+
// Precondition. Nothing in the block — receiver, `from`, `where`, `select`,
|
|
92
|
+
// `order by`, bound, `follow` — reads a scope at depth 1..depth (an enclosing
|
|
93
|
+
// row), and the block lifts nothing (a lift is a side effect on an enclosing
|
|
94
|
+
// scope that must recur per row).
|
|
95
|
+
//
|
|
96
|
+
// Argument. The block's value is a function of the root scope, the bindings
|
|
97
|
+
// and its own rows only, all constant within a run, so every evaluation yields
|
|
98
|
+
// the same value or raises the same error. The engine evaluates it the first
|
|
99
|
+
// time the scan would (so an error surfaces at the same point) and returns the
|
|
100
|
+
// memoized value afterwards; a run that raised never reaches a second
|
|
101
|
+
// evaluation. The memoized value is shared by reference across the enclosing
|
|
102
|
+
// rows that project it.
|
|
103
|
+
|
|
104
|
+
export const invariantBlock: Rule = (plan) => {
|
|
105
|
+
if (plan.invariant) return null;
|
|
106
|
+
if (opHasLifts(plan.node)) return null;
|
|
107
|
+
if (opReadsScopeIn(plan.node, plan.depth, plan.depth)) return null;
|
|
108
|
+
return { ...plan, invariant: true };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ---- rule 3: exists / none / count from cardinality -------------------------------
|
|
112
|
+
//
|
|
113
|
+
// Precondition. A where-position `exists`, `none` or `count` without `distinct`
|
|
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
|
|
117
|
+
// matched set).
|
|
118
|
+
//
|
|
119
|
+
// Argument. With no residual predicate the matched rows are exactly the
|
|
120
|
+
// accessed rows (§13: the receiver's rows filtered by `where`), entering a row
|
|
121
|
+
// as a scope cannot raise, and the consumers only need the count after the
|
|
122
|
+
// bound (§18): `count` is `boundedCount(n)`, `exists` is `boundedCount(n) > 0`,
|
|
123
|
+
// `none` its negation — what the scan computes after materializing. When the
|
|
124
|
+
// probe is unavailable at run time the residual is the whole `where` and the
|
|
125
|
+
// engine takes the per-row path.
|
|
126
|
+
|
|
127
|
+
export const cardinalityOnly: Rule = (plan) => {
|
|
128
|
+
if (plan.fromCardinality) return null;
|
|
129
|
+
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.where !== null && plan.residual !== null) return null;
|
|
132
|
+
return { ...plan, fromCardinality: true };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** The rules the engine applies by default, in order. */
|
|
136
|
+
export const DEFAULT_RULES: readonly Rule[] = [correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly];
|