@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
package/dist/engine.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Query } from "./ast.js";
|
|
1
|
+
import type { Query, OpNode } from "./ast.js";
|
|
2
2
|
import type { DataContext } from "./context.js";
|
|
3
|
+
import type { Rule } from "./optimize/index.js";
|
|
3
4
|
/** The shaped result of a top-level query, discriminated by consumer. */
|
|
4
5
|
export type OqxResult = {
|
|
5
6
|
consumer: "collect";
|
|
@@ -24,9 +25,47 @@ export type OqxResult = {
|
|
|
24
25
|
export interface Engine {
|
|
25
26
|
run(query: Query, bindings: readonly unknown[]): OqxResult;
|
|
26
27
|
}
|
|
28
|
+
/** What the optimizer did at run time (for tests and diagnostics): an index
|
|
29
|
+
* built (or taken from the context) for a collection and path, a probe that
|
|
30
|
+
* selected a bucket, a context index that answered a probe with rows directly
|
|
31
|
+
* (`lookupRows`: the collection was never materialized), a memoized invariant
|
|
32
|
+
* block reused, a consumer answered from a cardinality, or a probe that fell
|
|
33
|
+
* back to the scan. */
|
|
34
|
+
export type TraceEvent = {
|
|
35
|
+
kind: "index";
|
|
36
|
+
path: readonly string[];
|
|
37
|
+
rows: number;
|
|
38
|
+
source: "engine" | "context";
|
|
39
|
+
} | {
|
|
40
|
+
kind: "probe";
|
|
41
|
+
paths: readonly (readonly string[])[];
|
|
42
|
+
candidates: number;
|
|
43
|
+
} | {
|
|
44
|
+
kind: "lookup";
|
|
45
|
+
path: readonly string[];
|
|
46
|
+
candidates: number;
|
|
47
|
+
} | {
|
|
48
|
+
kind: "memo";
|
|
49
|
+
op: OpNode;
|
|
50
|
+
} | {
|
|
51
|
+
kind: "cardinality";
|
|
52
|
+
count: number;
|
|
53
|
+
} | {
|
|
54
|
+
kind: "fallback";
|
|
55
|
+
reason: "unstable" | "index" | "probe-value" | "not-a-collection";
|
|
56
|
+
};
|
|
57
|
+
export interface InMemoryEngineOptions {
|
|
58
|
+
/** The optimizer rules to apply to nested blocks (default `DEFAULT_RULES`);
|
|
59
|
+
* `[]` runs the naive scan everywhere. */
|
|
60
|
+
rules?: readonly Rule[];
|
|
61
|
+
/** Receives a `TraceEvent` for each optimizer action during `run`. */
|
|
62
|
+
trace?: (event: TraceEvent) => void;
|
|
63
|
+
}
|
|
27
64
|
export declare class InMemoryEngine implements Engine {
|
|
28
65
|
private ctx;
|
|
29
|
-
|
|
66
|
+
private rules;
|
|
67
|
+
private trace;
|
|
68
|
+
constructor(context?: DataContext, options?: InMemoryEngineOptions);
|
|
30
69
|
run(query: Query, bindings?: readonly unknown[]): OqxResult;
|
|
31
70
|
private boundOf;
|
|
32
71
|
private matches;
|
|
@@ -41,13 +80,27 @@ export declare class InMemoryEngine implements Engine {
|
|
|
41
80
|
private itemValue;
|
|
42
81
|
private evalWhere;
|
|
43
82
|
private evalWhereOp;
|
|
83
|
+
private evalWhereOpPlanned;
|
|
44
84
|
private opRows;
|
|
45
85
|
private anyMatch;
|
|
46
86
|
private iterMatchRows;
|
|
47
87
|
private matchRows;
|
|
88
|
+
private planOf;
|
|
89
|
+
private memoized;
|
|
90
|
+
private access;
|
|
91
|
+
private scan;
|
|
92
|
+
private reproject;
|
|
93
|
+
private materialize;
|
|
94
|
+
private lookupRows;
|
|
95
|
+
private probe;
|
|
96
|
+
private outerValue;
|
|
97
|
+
private contextIndex;
|
|
98
|
+
private indexFor;
|
|
48
99
|
private evalCollectValue;
|
|
100
|
+
private evalCollectValuePlanned;
|
|
49
101
|
private sortScopes;
|
|
50
102
|
private evalExpr;
|
|
103
|
+
private evalRaw;
|
|
51
104
|
private resolveIn;
|
|
52
105
|
private evalCall;
|
|
53
106
|
}
|
package/dist/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
|
package/dist/engine.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
// and `&&` evaluates strictly left to right — evaluation order is observable
|
|
9
9
|
// through errors, so the engine never reorders conjuncts.)
|
|
10
10
|
//
|
|
11
|
+
// Nested blocks run through the optimizer (`./optimize`): each block gets a
|
|
12
|
+
// `BlockPlan` the engine only EXECUTES (`access`), degrading to the plain scan
|
|
13
|
+
// whenever the planned access path is unavailable at run time. The rules and
|
|
14
|
+
// their semantics arguments live in `optimize/rules.ts`; `rules: []` turns the
|
|
15
|
+
// optimizer off, which the conformance suite uses to compare both paths.
|
|
16
|
+
//
|
|
11
17
|
// Name resolution is strictly lexical and LOCAL: a bare identifier is read from
|
|
12
18
|
// the current scope only, and an enclosing scope is reached solely through an
|
|
13
19
|
// explicit `^name` (exactly one scope out per caret). There is no implicit
|
|
@@ -15,21 +21,32 @@
|
|
|
15
21
|
import { DefaultContext } from "./context.js";
|
|
16
22
|
import { OqxError } from "./errors.js";
|
|
17
23
|
import { relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, } from "./semantics.js";
|
|
24
|
+
import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.js";
|
|
18
25
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
19
26
|
const KEY = "$key";
|
|
20
27
|
const HARD_DEPTH_CAP = 8;
|
|
21
28
|
const UNBOUNDED = { offset: 0, limit: null };
|
|
22
29
|
export class InMemoryEngine {
|
|
23
30
|
ctx;
|
|
24
|
-
|
|
31
|
+
rules;
|
|
32
|
+
trace;
|
|
33
|
+
constructor(context = new DefaultContext(), options = {}) {
|
|
25
34
|
this.ctx = context;
|
|
35
|
+
this.rules = options.rules ?? DEFAULT_RULES;
|
|
36
|
+
this.trace = options.trace;
|
|
26
37
|
}
|
|
27
38
|
run(query, bindings = []) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
const run = {
|
|
40
|
+
rules: this.rules,
|
|
41
|
+
bindingCount: bindings.length,
|
|
42
|
+
collections: new WeakMap(),
|
|
43
|
+
stableCollections: new Map(),
|
|
44
|
+
memo: new Map(),
|
|
45
|
+
};
|
|
46
|
+
if (this.trace)
|
|
47
|
+
run.trace = this.trace;
|
|
48
|
+
const root = { row: null, parent: null, bindings, run };
|
|
49
|
+
const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
|
|
33
50
|
const bound = this.boundOf(query, root);
|
|
34
51
|
if (query.follow)
|
|
35
52
|
return this.runFollow(query, rows, root, bound);
|
|
@@ -83,7 +100,7 @@ export class InMemoryEngine {
|
|
|
83
100
|
boundOf(b, enclosing) {
|
|
84
101
|
if (!b.limit && !b.offset)
|
|
85
102
|
return UNBOUNDED;
|
|
86
|
-
const scope = enclosing.parent === null ? enclosing :
|
|
103
|
+
const scope = enclosing.parent === null ? enclosing : rowless(enclosing);
|
|
87
104
|
const read = (e, word) => {
|
|
88
105
|
if (!e)
|
|
89
106
|
return null;
|
|
@@ -113,7 +130,7 @@ export class InMemoryEngine {
|
|
|
113
130
|
// goes through this, so entries behave the same at the top level, in nested
|
|
114
131
|
// blocks, as `from` re-projections, and as follow seeds.
|
|
115
132
|
enter(row, parent, extra = {}) {
|
|
116
|
-
const s = { row, parent, bindings: parent.bindings };
|
|
133
|
+
const s = { row, parent, bindings: parent.bindings, run: parent.run };
|
|
117
134
|
if (extra.lifts)
|
|
118
135
|
s.lifts = extra.lifts;
|
|
119
136
|
if (extra.meta)
|
|
@@ -171,14 +188,39 @@ export class InMemoryEngine {
|
|
|
171
188
|
// • identity for cycle detection + `distinct` is `by <expr>` when given, else
|
|
172
189
|
// `ctx.identity(row)` (an entry's identity is its value's); identities are
|
|
173
190
|
// compared structurally via `canonicalKey`, and `$ordinal` paths compare
|
|
174
|
-
// component-wise (`comparePath`), so `10` follows `9
|
|
191
|
+
// component-wise (`comparePath`), so `10` follows `9`;
|
|
192
|
+
// • a row's successors are the destinations' rows in source order — a plain
|
|
193
|
+
// relation read in the row's scope, a destination block evaluated as a
|
|
194
|
+
// select-position directive in that scope (so `^` inside it is the frontier
|
|
195
|
+
// row and its `^people` receiver resolves one scope further out) — filtered
|
|
196
|
+
// by the follow `where`, then unioned by identity within the step (a
|
|
197
|
+
// recurring identity is kept once, at its first position);
|
|
198
|
+
// • the follow `where` reads the candidate's scope hung off the FRONTIER
|
|
199
|
+
// row's scope: a bare name is the candidate's, `^name` the row being
|
|
200
|
+
// expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
|
|
201
|
+
// the occurrence's own scope with `^` the enclosing scope, as the body does.
|
|
175
202
|
followWalk(seedRows, follow, parent) {
|
|
176
203
|
const cap = follow.depth ?? HARD_DEPTH_CAP;
|
|
177
204
|
const scopeFor = (row) => this.enter(row, parent);
|
|
178
205
|
const identityOf = (row) => follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
|
|
179
206
|
const succOf = (row) => {
|
|
180
|
-
const
|
|
181
|
-
|
|
207
|
+
const rowScope = scopeFor(row);
|
|
208
|
+
const raw = [];
|
|
209
|
+
for (const dest of follow.destinations) {
|
|
210
|
+
const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
|
|
211
|
+
raw.push(...this.rowsOf(v));
|
|
212
|
+
}
|
|
213
|
+
const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where, this.enter(x, rowScope)))) : raw;
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
const out = [];
|
|
216
|
+
for (const x of kept) {
|
|
217
|
+
const key = canonicalKey(identityOf(x));
|
|
218
|
+
if (seen.has(key))
|
|
219
|
+
continue;
|
|
220
|
+
seen.add(key);
|
|
221
|
+
out.push(x);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
182
224
|
};
|
|
183
225
|
const frontierHit = (row) => follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
|
|
184
226
|
const walked = [];
|
|
@@ -278,12 +320,32 @@ export class InMemoryEngine {
|
|
|
278
320
|
evalWhereOp(op, scope) {
|
|
279
321
|
if (op.sub.follow)
|
|
280
322
|
throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
|
|
323
|
+
const plan = this.planOf(op, scope);
|
|
324
|
+
if (!plan.invariant)
|
|
325
|
+
return this.evalWhereOpPlanned(op, plan, scope);
|
|
326
|
+
return this.memoized(op, scope, () => this.evalWhereOpPlanned(op, plan, scope));
|
|
327
|
+
}
|
|
328
|
+
evalWhereOpPlanned(op, plan, scope) {
|
|
281
329
|
const bound = this.boundOf(op.sub, scope);
|
|
282
|
-
if (op.op === "exists" || op.op === "none") {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
330
|
+
if (op.op === "exists" || op.op === "none" || op.op === "count") {
|
|
331
|
+
const acc = this.access(op, plan, scope);
|
|
332
|
+
let n;
|
|
333
|
+
if (plan.fromCardinality && acc.where === null) {
|
|
334
|
+
// Nothing left to evaluate per row: the accessed rows ARE the matched
|
|
335
|
+
// rows, and the consumer only needs their count after the bound.
|
|
336
|
+
n = boundedCount(acc.rows.length, bound);
|
|
337
|
+
scope.run.trace?.({ kind: "cardinality", count: n });
|
|
338
|
+
}
|
|
339
|
+
else if (op.op !== "count" && bound === UNBOUNDED) {
|
|
340
|
+
// Unbounded exists/none: stop at the first match (dedup cannot change
|
|
341
|
+
// emptiness). Bounded: the offset/limit decide emptiness, so materialize.
|
|
342
|
+
n = this.anyMatch(op, scope, acc) ? 1 : 0;
|
|
343
|
+
}
|
|
344
|
+
else
|
|
345
|
+
n = this.opRows(op, scope, bound, acc).length;
|
|
346
|
+
if (op.op === "count")
|
|
347
|
+
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
348
|
+
return op.op === "exists" ? n > 0 : n === 0;
|
|
287
349
|
}
|
|
288
350
|
if (op.op === "collect") {
|
|
289
351
|
const matched = this.opRows(op, scope, bound);
|
|
@@ -303,51 +365,218 @@ export class InMemoryEngine {
|
|
|
303
365
|
}
|
|
304
366
|
return matched.length > 0;
|
|
305
367
|
}
|
|
306
|
-
if (op.op === "count") {
|
|
307
|
-
const n = this.opRows(op, scope, bound).length;
|
|
308
|
-
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
309
|
-
}
|
|
310
368
|
return this.opRows(op, scope, bound).length > 0;
|
|
311
369
|
}
|
|
312
370
|
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
313
|
-
opRows(op, scope, bound) {
|
|
314
|
-
let scopes = this.matchRows(op, scope);
|
|
371
|
+
opRows(op, scope, bound, acc) {
|
|
372
|
+
let scopes = this.matchRows(op, scope, acc);
|
|
315
373
|
this.sortScopes(scopes, op.sub.orderBy);
|
|
316
374
|
if (op.distinct)
|
|
317
375
|
scopes = this.dedupByProjection(scopes, op.sub);
|
|
318
376
|
return sliceBound(scopes, bound);
|
|
319
377
|
}
|
|
320
|
-
// Short-circuiting existence check over a
|
|
321
|
-
|
|
322
|
-
|
|
378
|
+
// Short-circuiting existence check: over a probe's bucket this stops at the
|
|
379
|
+
// first row that passes the residual, so `exists`/`none` never materialize it.
|
|
380
|
+
anyMatch(op, scope, acc) {
|
|
381
|
+
for (const s of this.iterMatchRows(op, scope, acc)) {
|
|
323
382
|
void s;
|
|
324
383
|
return true;
|
|
325
384
|
}
|
|
326
385
|
return false;
|
|
327
386
|
}
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
387
|
+
// The block's accessed rows, entered as scopes under `scope`, filtered by the
|
|
388
|
+
// predicate the access left to evaluate.
|
|
389
|
+
*iterMatchRows(op, scope, acc) {
|
|
390
|
+
const { rows, where } = acc ?? this.access(op, this.planOf(op, scope), scope);
|
|
332
391
|
for (const r of rows) {
|
|
333
392
|
const s = this.enter(r, scope, { lifts: {} });
|
|
334
|
-
if (!
|
|
393
|
+
if (!where || this.evalWhere(where, s))
|
|
335
394
|
yield s;
|
|
336
395
|
}
|
|
337
396
|
}
|
|
338
|
-
matchRows(op, scope) {
|
|
339
|
-
return Array.from(this.iterMatchRows(op, scope));
|
|
397
|
+
matchRows(op, scope, acc) {
|
|
398
|
+
return Array.from(this.iterMatchRows(op, scope, acc));
|
|
399
|
+
}
|
|
400
|
+
// ---- planned access -------------------------------------------------------
|
|
401
|
+
//
|
|
402
|
+
// The engine EXECUTES a block's `BlockPlan`; the rules and the semantics
|
|
403
|
+
// argument for each live in `./optimize` (rules.ts). `access` is the one path
|
|
404
|
+
// every consumer takes to a block's rows, and whatever the plan asked for
|
|
405
|
+
// that cannot be served at run time degrades to the scan the plan started from.
|
|
406
|
+
planOf(op, scope) {
|
|
407
|
+
return planFor(op, scopeDepth(scope), { bindingCount: scope.run.bindingCount }, scope.run.rules);
|
|
408
|
+
}
|
|
409
|
+
// An invariant block (rule 2) is evaluated the first time it is reached and
|
|
410
|
+
// its value reused, by reference, for every other enclosing row.
|
|
411
|
+
memoized(op, scope, compute) {
|
|
412
|
+
const memo = scope.run.memo;
|
|
413
|
+
if (memo.has(op)) {
|
|
414
|
+
scope.run.trace?.({ kind: "memo", op });
|
|
415
|
+
return memo.get(op);
|
|
416
|
+
}
|
|
417
|
+
const value = compute();
|
|
418
|
+
memo.set(op, value);
|
|
419
|
+
return value;
|
|
420
|
+
}
|
|
421
|
+
// A block's rows and the predicate left to evaluate over each. A correlated
|
|
422
|
+
// plan is served, in order of preference, by a context index that yields rows
|
|
423
|
+
// (`lookupRows` — asked on every sight: it costs nothing and the collection
|
|
424
|
+
// is never read), by a probe on the engine's own index (built on the
|
|
425
|
+
// collection's second sight, or at once for a statically stable receiver: a
|
|
426
|
+
// collection seen once is cheaper to scan than to index), else by the scan.
|
|
427
|
+
// A statically stable receiver (rule 1b) is evaluated once per run; any other
|
|
428
|
+
// is keyed by its value's identity, so a fresh array per row is never indexed.
|
|
429
|
+
access(op, plan, scope) {
|
|
430
|
+
const run = scope.run;
|
|
431
|
+
let state = plan.receiverStable ? run.stableCollections.get(op) : undefined;
|
|
432
|
+
if (!state) {
|
|
433
|
+
const value = this.evalRaw(op.receiver, scope);
|
|
434
|
+
if (plan.receiverStable)
|
|
435
|
+
run.stableCollections.set(op, (state = newCollection(value)));
|
|
436
|
+
else if (plan.correlated.length === 0)
|
|
437
|
+
return this.scan(this.rowsOf(value), op, scope);
|
|
438
|
+
else if (value !== null && typeof value === "object") {
|
|
439
|
+
state = run.collections.get(value);
|
|
440
|
+
if (!state)
|
|
441
|
+
run.collections.set(value, (state = newCollection(value)));
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
run.trace?.({ kind: "fallback", reason: "not-a-collection" });
|
|
445
|
+
return this.scan(this.rowsOf(value), op, scope);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
state.seen++;
|
|
449
|
+
if (plan.correlated.length > 0) {
|
|
450
|
+
const direct = this.lookupRows(plan, state, scope);
|
|
451
|
+
if (direct)
|
|
452
|
+
return direct;
|
|
453
|
+
if (state.seen >= 2 || plan.receiverStable) {
|
|
454
|
+
const probed = this.probe(plan, state, scope);
|
|
455
|
+
if (probed)
|
|
456
|
+
return { rows: probed, where: plan.residual };
|
|
457
|
+
}
|
|
458
|
+
else
|
|
459
|
+
run.trace?.({ kind: "fallback", reason: "unstable" });
|
|
460
|
+
}
|
|
461
|
+
return this.scan(this.materialize(state), op, scope);
|
|
462
|
+
}
|
|
463
|
+
// The plain scan: the rows, re-projected by `from`, with the whole `where`.
|
|
464
|
+
scan(rows, op, scope) {
|
|
465
|
+
return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
|
|
466
|
+
}
|
|
467
|
+
reproject(rows, from, scope) {
|
|
468
|
+
for (const proj of from)
|
|
469
|
+
rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
|
|
470
|
+
return rows;
|
|
471
|
+
}
|
|
472
|
+
materialize(state) {
|
|
473
|
+
return (state.rows ??= this.rowsOf(state.value));
|
|
474
|
+
}
|
|
475
|
+
// A probe answered by a context index that yields rows directly, before the
|
|
476
|
+
// collection is materialized: one correlation is probed (`lookupOrder`) and
|
|
477
|
+
// every other conjunct stays in place per selected row (`residualWithout`).
|
|
478
|
+
lookupRows(plan, state, scope) {
|
|
479
|
+
for (const c of lookupOrder(plan)) {
|
|
480
|
+
const index = this.contextIndex(state, c);
|
|
481
|
+
if (!index?.lookupRows)
|
|
482
|
+
continue;
|
|
483
|
+
const outer = this.outerValue(c, scope);
|
|
484
|
+
if (!outer)
|
|
485
|
+
return null;
|
|
486
|
+
const rows = Array.from(index.lookupRows(outer.value));
|
|
487
|
+
scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
|
|
488
|
+
return { rows, where: residualWithout(plan, c) };
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
// The engine's probe: one bucket per correlation, intersected smallest-first;
|
|
493
|
+
// the candidates come back in receiver order. `null` (an index that could
|
|
494
|
+
// not be built, an outer value whose evaluation threw) leaves the scan in charge.
|
|
495
|
+
probe(plan, state, scope) {
|
|
496
|
+
const run = scope.run;
|
|
497
|
+
const rows = this.materialize(state);
|
|
498
|
+
const buckets = [];
|
|
499
|
+
for (const c of plan.correlated) {
|
|
500
|
+
const index = this.indexFor(state, c, scope);
|
|
501
|
+
if (!index) {
|
|
502
|
+
run.trace?.({ kind: "fallback", reason: "index" });
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const outer = this.outerValue(c, scope);
|
|
506
|
+
if (!outer)
|
|
507
|
+
return null;
|
|
508
|
+
buckets.push(index.lookup(outer.value));
|
|
509
|
+
}
|
|
510
|
+
buckets.sort((a, b) => a.length - b.length);
|
|
511
|
+
let candidates = buckets[0];
|
|
512
|
+
for (let i = 1; i < buckets.length && candidates.length > 0; i++)
|
|
513
|
+
candidates = intersectPositions(candidates, buckets[i]);
|
|
514
|
+
run.trace?.({ kind: "probe", paths: plan.correlated.map((c) => c.path), candidates: candidates.length });
|
|
515
|
+
return candidates.map((i) => rows[i]);
|
|
516
|
+
}
|
|
517
|
+
// A correlation's outer side, read once in a row-less scope inside the block
|
|
518
|
+
// (as a bound is); `null` when it threw — the scan then raises it in place.
|
|
519
|
+
outerValue(c, scope) {
|
|
520
|
+
try {
|
|
521
|
+
return { value: this.evalExpr(c.outer, rowless(scope)) };
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
scope.run.trace?.({ kind: "fallback", reason: "probe-value" });
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// The context's pre-built index on a correlation's local path, if it offers
|
|
529
|
+
// one (asked once per collection and path; a refusal is cached too).
|
|
530
|
+
contextIndex(state, c) {
|
|
531
|
+
const key = pathKey(c.path);
|
|
532
|
+
let index = state.contextIndexes.get(key);
|
|
533
|
+
if (index === undefined)
|
|
534
|
+
state.contextIndexes.set(key, (index = this.ctx.indexFor?.(state.value, c.path) ?? null));
|
|
535
|
+
return index;
|
|
536
|
+
}
|
|
537
|
+
// The index serving a correlation's local path: the context's, else one built
|
|
538
|
+
// here by evaluating the local expression in each row's scope — the same read
|
|
539
|
+
// the scan performs, so the keys are exactly what `==` would compare. A throw
|
|
540
|
+
// while building (a context that rejects a read) leaves the path un-indexed.
|
|
541
|
+
indexFor(state, c, scope) {
|
|
542
|
+
const key = pathKey(c.path);
|
|
543
|
+
const hit = state.indexes.get(key);
|
|
544
|
+
if (hit !== undefined)
|
|
545
|
+
return hit;
|
|
546
|
+
const rows = this.materialize(state);
|
|
547
|
+
let index = this.contextIndex(state, c);
|
|
548
|
+
let source = "context";
|
|
549
|
+
if (!index) {
|
|
550
|
+
source = "engine";
|
|
551
|
+
const built = new HashIndex();
|
|
552
|
+
try {
|
|
553
|
+
for (let i = 0; i < rows.length; i++)
|
|
554
|
+
built.add(this.evalExpr(c.local, this.enter(rows[i], scope)), i);
|
|
555
|
+
index = built;
|
|
556
|
+
}
|
|
557
|
+
catch { /* un-indexed: the scan serves this path */ }
|
|
558
|
+
}
|
|
559
|
+
if (index)
|
|
560
|
+
scope.run.trace?.({ kind: "index", path: c.path, rows: rows.length, source });
|
|
561
|
+
state.indexes.set(key, index);
|
|
562
|
+
return index;
|
|
340
563
|
}
|
|
341
564
|
// A select-position collect/first/single, optionally recursive via `follow`.
|
|
342
565
|
evalCollectValue(op, scope) {
|
|
566
|
+
const plan = this.planOf(op, scope);
|
|
567
|
+
if (!plan.invariant)
|
|
568
|
+
return this.evalCollectValuePlanned(op, plan, scope);
|
|
569
|
+
return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
|
|
570
|
+
}
|
|
571
|
+
evalCollectValuePlanned(op, plan, scope) {
|
|
343
572
|
const sub = op.sub;
|
|
344
573
|
const bound = this.boundOf(sub, scope);
|
|
345
574
|
let scopes;
|
|
346
575
|
if (sub.follow) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const seeds =
|
|
576
|
+
// The seeds are the matched rows themselves (`followWalk` enters them); a
|
|
577
|
+
// follow block is never correlated, so `where` here is the whole predicate.
|
|
578
|
+
const { rows, where } = this.access(op, plan, scope);
|
|
579
|
+
const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
|
|
351
580
|
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
|
|
352
581
|
this.sortScopes(scopes, sub.orderBy);
|
|
353
582
|
if (op.distinct)
|
|
@@ -355,7 +584,7 @@ export class InMemoryEngine {
|
|
|
355
584
|
scopes = sliceBound(scopes, bound);
|
|
356
585
|
}
|
|
357
586
|
else {
|
|
358
|
-
scopes = this.opRows(op, scope, bound);
|
|
587
|
+
scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
|
|
359
588
|
}
|
|
360
589
|
switch (op.op) {
|
|
361
590
|
case "collect": return scopes.map((s) => this.projectRow(sub, s));
|
|
@@ -395,7 +624,18 @@ export class InMemoryEngine {
|
|
|
395
624
|
});
|
|
396
625
|
}
|
|
397
626
|
// ---- scalar expression evaluation -----------------------------------------
|
|
627
|
+
// An expression as a VALUE: whatever `evalRaw` yields, with a context's lazy
|
|
628
|
+
// stand-in resolved (`DataContext.materialize`), so no operand, argument, key
|
|
629
|
+
// or projected item ever observes a value the context meant as a collection
|
|
630
|
+
// it had not read yet.
|
|
398
631
|
evalExpr(e, scope) {
|
|
632
|
+
const v = this.evalRaw(e, scope);
|
|
633
|
+
return this.ctx.materialize ? this.ctx.materialize(v) : v;
|
|
634
|
+
}
|
|
635
|
+
// An expression as the context handed it out — for ROW POSITION only (the
|
|
636
|
+
// source, a receiver, a `from`, a `follow` destination), where the value goes
|
|
637
|
+
// straight to `toRows` / `indexFor`. Operands inside it are values (`evalExpr`).
|
|
638
|
+
evalRaw(e, scope) {
|
|
399
639
|
switch (e.kind) {
|
|
400
640
|
case "lit": return e.value;
|
|
401
641
|
case "binding":
|
|
@@ -491,6 +731,24 @@ export class InMemoryEngine {
|
|
|
491
731
|
}
|
|
492
732
|
}
|
|
493
733
|
// ---- free helpers -----------------------------------------------------------
|
|
734
|
+
function newCollection(value) {
|
|
735
|
+
return { value, seen: 0, rows: null, indexes: new Map(), contextIndexes: new Map() };
|
|
736
|
+
}
|
|
737
|
+
function pathKey(path) {
|
|
738
|
+
return JSON.stringify(path);
|
|
739
|
+
}
|
|
740
|
+
// A row-less scope INSIDE a block: a bare name is absent, `^name` is the
|
|
741
|
+
// enclosing row — how a block's bound and a probe's outer value are read.
|
|
742
|
+
function rowless(enclosing) {
|
|
743
|
+
return { row: undefined, parent: enclosing, bindings: enclosing.bindings, run: enclosing.run };
|
|
744
|
+
}
|
|
745
|
+
// How many scopes out the root is (the root itself is 0).
|
|
746
|
+
function scopeDepth(scope) {
|
|
747
|
+
let d = 0;
|
|
748
|
+
for (let s = scope; s.parent !== null; s = s.parent)
|
|
749
|
+
d++;
|
|
750
|
+
return d;
|
|
751
|
+
}
|
|
494
752
|
function isRelOp(op) {
|
|
495
753
|
return op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
496
754
|
}
|