@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/src/engine.ts
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
|
|
@@ -22,6 +28,8 @@ import { OqxError } from "./errors.ts";
|
|
|
22
28
|
import {
|
|
23
29
|
relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange,
|
|
24
30
|
} from "./semantics.ts";
|
|
31
|
+
import type { BlockPlan, Correlation, Rule, RowIndex } from "./optimize/index.ts";
|
|
32
|
+
import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.ts";
|
|
25
33
|
|
|
26
34
|
/** The shaped result of a top-level query, discriminated by consumer. */
|
|
27
35
|
export type OqxResult =
|
|
@@ -47,10 +55,59 @@ interface Scope {
|
|
|
47
55
|
row: unknown;
|
|
48
56
|
parent: Scope | null;
|
|
49
57
|
bindings: readonly unknown[];
|
|
58
|
+
run: RunState;
|
|
50
59
|
lifts?: Record<string, unknown>;
|
|
51
60
|
meta?: Record<string, unknown>;
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
// State that lives for exactly one `run()` (nothing here outlives it, so data
|
|
64
|
+
// that changes between runs is never served stale): the rule set, the receiver
|
|
65
|
+
// collections seen — by the receiver VALUE's identity, or by the block when its
|
|
66
|
+
// receiver is statically stable (see `access`) — the values of invariant
|
|
67
|
+
// blocks, and an optional trace sink.
|
|
68
|
+
interface RunState {
|
|
69
|
+
rules: readonly Rule[];
|
|
70
|
+
bindingCount: number;
|
|
71
|
+
collections: WeakMap<object, CollectionState>;
|
|
72
|
+
stableCollections: Map<OpNode, CollectionState>;
|
|
73
|
+
memo: Map<OpNode, unknown>;
|
|
74
|
+
trace?: (event: TraceEvent) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A receiver collection seen during a run: its value (what `toRows` coerces and
|
|
78
|
+
// what `indexFor` is asked about), how many times it was reached, its rows once
|
|
79
|
+
// materialized, and per local path the index serving it and what the context
|
|
80
|
+
// offered (`null`: none could be built / none was offered — the scan serves it).
|
|
81
|
+
interface CollectionState {
|
|
82
|
+
value: unknown;
|
|
83
|
+
seen: number;
|
|
84
|
+
rows: unknown[] | null;
|
|
85
|
+
indexes: Map<string, RowIndex | null>;
|
|
86
|
+
contextIndexes: Map<string, RowIndex | null>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the optimizer did at run time (for tests and diagnostics): an index
|
|
90
|
+
* built (or taken from the context) for a collection and path, a probe that
|
|
91
|
+
* selected a bucket, a context index that answered a probe with rows directly
|
|
92
|
+
* (`lookupRows`: the collection was never materialized), a memoized invariant
|
|
93
|
+
* block reused, a consumer answered from a cardinality, or a probe that fell
|
|
94
|
+
* back to the scan. */
|
|
95
|
+
export type TraceEvent =
|
|
96
|
+
| { kind: "index"; path: readonly string[]; rows: number; source: "engine" | "context" }
|
|
97
|
+
| { kind: "probe"; paths: readonly (readonly string[])[]; candidates: number }
|
|
98
|
+
| { kind: "lookup"; path: readonly string[]; candidates: number }
|
|
99
|
+
| { kind: "memo"; op: OpNode }
|
|
100
|
+
| { kind: "cardinality"; count: number }
|
|
101
|
+
| { kind: "fallback"; reason: "unstable" | "index" | "probe-value" | "not-a-collection" };
|
|
102
|
+
|
|
103
|
+
export interface InMemoryEngineOptions {
|
|
104
|
+
/** The optimizer rules to apply to nested blocks (default `DEFAULT_RULES`);
|
|
105
|
+
* `[]` runs the naive scan everywhere. */
|
|
106
|
+
rules?: readonly Rule[];
|
|
107
|
+
/** Receives a `TraceEvent` for each optimizer action during `run`. */
|
|
108
|
+
trace?: (event: TraceEvent) => void;
|
|
109
|
+
}
|
|
110
|
+
|
|
54
111
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
55
112
|
const KEY = "$key";
|
|
56
113
|
const HARD_DEPTH_CAP = 8;
|
|
@@ -65,17 +122,26 @@ const UNBOUNDED: Bound = { offset: 0, limit: null };
|
|
|
65
122
|
|
|
66
123
|
export class InMemoryEngine implements Engine {
|
|
67
124
|
private ctx: DataContext;
|
|
125
|
+
private rules: readonly Rule[];
|
|
126
|
+
private trace: ((event: TraceEvent) => void) | undefined;
|
|
68
127
|
|
|
69
|
-
constructor(context: DataContext = new DefaultContext()) {
|
|
128
|
+
constructor(context: DataContext = new DefaultContext(), options: InMemoryEngineOptions = {}) {
|
|
70
129
|
this.ctx = context;
|
|
130
|
+
this.rules = options.rules ?? DEFAULT_RULES;
|
|
131
|
+
this.trace = options.trace;
|
|
71
132
|
}
|
|
72
133
|
|
|
73
134
|
run(query: Query, bindings: readonly unknown[] = []): OqxResult {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
135
|
+
const run: RunState = {
|
|
136
|
+
rules: this.rules,
|
|
137
|
+
bindingCount: bindings.length,
|
|
138
|
+
collections: new WeakMap(),
|
|
139
|
+
stableCollections: new Map(),
|
|
140
|
+
memo: new Map(),
|
|
141
|
+
};
|
|
142
|
+
if (this.trace) run.trace = this.trace;
|
|
143
|
+
const root: Scope = { row: null, parent: null, bindings, run };
|
|
144
|
+
const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
|
|
79
145
|
|
|
80
146
|
const bound = this.boundOf(query, root);
|
|
81
147
|
if (query.follow) return this.runFollow(query, rows, root, bound);
|
|
@@ -123,7 +189,7 @@ export class InMemoryEngine implements Engine {
|
|
|
123
189
|
// is the root. Each must be a non-negative integer.
|
|
124
190
|
private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
|
|
125
191
|
if (!b.limit && !b.offset) return UNBOUNDED;
|
|
126
|
-
const scope: Scope = enclosing.parent === null ? enclosing :
|
|
192
|
+
const scope: Scope = enclosing.parent === null ? enclosing : rowless(enclosing);
|
|
127
193
|
const read = (e: Expr | undefined, word: string): number | null => {
|
|
128
194
|
if (!e) return null;
|
|
129
195
|
const v = this.evalExpr(e, scope);
|
|
@@ -155,7 +221,7 @@ export class InMemoryEngine implements Engine {
|
|
|
155
221
|
// goes through this, so entries behave the same at the top level, in nested
|
|
156
222
|
// blocks, as `from` re-projections, and as follow seeds.
|
|
157
223
|
private enter(row: unknown, parent: Scope, extra: { lifts?: Record<string, unknown>; meta?: Record<string, unknown> } = {}): Scope {
|
|
158
|
-
const s: Scope = { row, parent, bindings: parent.bindings };
|
|
224
|
+
const s: Scope = { row, parent, bindings: parent.bindings, run: parent.run };
|
|
159
225
|
if (extra.lifts) s.lifts = extra.lifts;
|
|
160
226
|
if (extra.meta) s.meta = extra.meta;
|
|
161
227
|
if (isEntry(row)) {
|
|
@@ -212,15 +278,39 @@ export class InMemoryEngine implements Engine {
|
|
|
212
278
|
// • identity for cycle detection + `distinct` is `by <expr>` when given, else
|
|
213
279
|
// `ctx.identity(row)` (an entry's identity is its value's); identities are
|
|
214
280
|
// compared structurally via `canonicalKey`, and `$ordinal` paths compare
|
|
215
|
-
// component-wise (`comparePath`), so `10` follows `9
|
|
281
|
+
// component-wise (`comparePath`), so `10` follows `9`;
|
|
282
|
+
// • a row's successors are the destinations' rows in source order — a plain
|
|
283
|
+
// relation read in the row's scope, a destination block evaluated as a
|
|
284
|
+
// select-position directive in that scope (so `^` inside it is the frontier
|
|
285
|
+
// row and its `^people` receiver resolves one scope further out) — filtered
|
|
286
|
+
// by the follow `where`, then unioned by identity within the step (a
|
|
287
|
+
// recurring identity is kept once, at its first position);
|
|
288
|
+
// • the follow `where` reads the candidate's scope hung off the FRONTIER
|
|
289
|
+
// row's scope: a bare name is the candidate's, `^name` the row being
|
|
290
|
+
// expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
|
|
291
|
+
// the occurrence's own scope with `^` the enclosing scope, as the body does.
|
|
216
292
|
private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
|
|
217
293
|
const cap = follow.depth ?? HARD_DEPTH_CAP;
|
|
218
294
|
const scopeFor = (row: unknown): Scope => this.enter(row, parent);
|
|
219
295
|
const identityOf = (row: unknown): unknown =>
|
|
220
296
|
follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
|
|
221
297
|
const succOf = (row: unknown): unknown[] => {
|
|
222
|
-
const
|
|
223
|
-
|
|
298
|
+
const rowScope = scopeFor(row);
|
|
299
|
+
const raw: unknown[] = [];
|
|
300
|
+
for (const dest of follow.destinations) {
|
|
301
|
+
const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
|
|
302
|
+
raw.push(...this.rowsOf(v));
|
|
303
|
+
}
|
|
304
|
+
const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where!, this.enter(x, rowScope)))) : raw;
|
|
305
|
+
const seen = new Set<string>();
|
|
306
|
+
const out: unknown[] = [];
|
|
307
|
+
for (const x of kept) {
|
|
308
|
+
const key = canonicalKey(identityOf(x));
|
|
309
|
+
if (seen.has(key)) continue;
|
|
310
|
+
seen.add(key);
|
|
311
|
+
out.push(x);
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
224
314
|
};
|
|
225
315
|
const frontierHit = (row: unknown): boolean =>
|
|
226
316
|
follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
|
|
@@ -320,12 +410,28 @@ export class InMemoryEngine implements Engine {
|
|
|
320
410
|
|
|
321
411
|
private evalWhereOp(op: OpNode, scope: Scope): boolean {
|
|
322
412
|
if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
|
|
413
|
+
const plan = this.planOf(op, scope);
|
|
414
|
+
if (!plan.invariant) return this.evalWhereOpPlanned(op, plan, scope);
|
|
415
|
+
return this.memoized(op, scope, () => this.evalWhereOpPlanned(op, plan, scope));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private evalWhereOpPlanned(op: OpNode, plan: BlockPlan, scope: Scope): boolean {
|
|
323
419
|
const bound = this.boundOf(op.sub, scope);
|
|
324
|
-
if (op.op === "exists" || op.op === "none") {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
420
|
+
if (op.op === "exists" || op.op === "none" || op.op === "count") {
|
|
421
|
+
const acc = this.access(op, plan, scope);
|
|
422
|
+
let n: number;
|
|
423
|
+
if (plan.fromCardinality && acc.where === null) {
|
|
424
|
+
// Nothing left to evaluate per row: the accessed rows ARE the matched
|
|
425
|
+
// rows, and the consumer only needs their count after the bound.
|
|
426
|
+
n = boundedCount(acc.rows.length, bound);
|
|
427
|
+
scope.run.trace?.({ kind: "cardinality", count: n });
|
|
428
|
+
} else if (op.op !== "count" && bound === UNBOUNDED) {
|
|
429
|
+
// Unbounded exists/none: stop at the first match (dedup cannot change
|
|
430
|
+
// emptiness). Bounded: the offset/limit decide emptiness, so materialize.
|
|
431
|
+
n = this.anyMatch(op, scope, acc) ? 1 : 0;
|
|
432
|
+
} else n = this.opRows(op, scope, bound, acc).length;
|
|
433
|
+
if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
434
|
+
return op.op === "exists" ? n > 0 : n === 0;
|
|
329
435
|
}
|
|
330
436
|
if (op.op === "collect") {
|
|
331
437
|
const matched = this.opRows(op, scope, bound);
|
|
@@ -343,55 +449,207 @@ export class InMemoryEngine implements Engine {
|
|
|
343
449
|
}
|
|
344
450
|
return matched.length > 0;
|
|
345
451
|
}
|
|
346
|
-
if (op.op === "count") {
|
|
347
|
-
const n = this.opRows(op, scope, bound).length;
|
|
348
|
-
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
349
|
-
}
|
|
350
452
|
return this.opRows(op, scope, bound).length > 0;
|
|
351
453
|
}
|
|
352
454
|
|
|
353
455
|
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
354
|
-
private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
|
|
355
|
-
let scopes = this.matchRows(op, scope);
|
|
456
|
+
private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
|
|
457
|
+
let scopes = this.matchRows(op, scope, acc);
|
|
356
458
|
this.sortScopes(scopes, op.sub.orderBy);
|
|
357
459
|
if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
|
|
358
460
|
return sliceBound(scopes, bound);
|
|
359
461
|
}
|
|
360
462
|
|
|
361
|
-
// Short-circuiting existence check over a
|
|
362
|
-
|
|
363
|
-
|
|
463
|
+
// Short-circuiting existence check: over a probe's bucket this stops at the
|
|
464
|
+
// first row that passes the residual, so `exists`/`none` never materialize it.
|
|
465
|
+
private anyMatch(op: OpNode, scope: Scope, acc?: Access): boolean {
|
|
466
|
+
for (const s of this.iterMatchRows(op, scope, acc)) { void s; return true; }
|
|
364
467
|
return false;
|
|
365
468
|
}
|
|
366
469
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
470
|
+
// The block's accessed rows, entered as scopes under `scope`, filtered by the
|
|
471
|
+
// predicate the access left to evaluate.
|
|
472
|
+
private *iterMatchRows(op: OpNode, scope: Scope, acc?: Access): Generator<Scope> {
|
|
473
|
+
const { rows, where } = acc ?? this.access(op, this.planOf(op, scope), scope);
|
|
370
474
|
for (const r of rows) {
|
|
371
475
|
const s = this.enter(r, scope, { lifts: {} });
|
|
372
|
-
if (!
|
|
476
|
+
if (!where || this.evalWhere(where, s)) yield s;
|
|
373
477
|
}
|
|
374
478
|
}
|
|
375
479
|
|
|
376
|
-
private matchRows(op: OpNode, scope: Scope): Scope[] {
|
|
377
|
-
return Array.from(this.iterMatchRows(op, scope));
|
|
480
|
+
private matchRows(op: OpNode, scope: Scope, acc?: Access): Scope[] {
|
|
481
|
+
return Array.from(this.iterMatchRows(op, scope, acc));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---- planned access -------------------------------------------------------
|
|
485
|
+
//
|
|
486
|
+
// The engine EXECUTES a block's `BlockPlan`; the rules and the semantics
|
|
487
|
+
// argument for each live in `./optimize` (rules.ts). `access` is the one path
|
|
488
|
+
// every consumer takes to a block's rows, and whatever the plan asked for
|
|
489
|
+
// that cannot be served at run time degrades to the scan the plan started from.
|
|
490
|
+
|
|
491
|
+
private planOf(op: OpNode, scope: Scope): BlockPlan {
|
|
492
|
+
return planFor(op, scopeDepth(scope), { bindingCount: scope.run.bindingCount }, scope.run.rules);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// An invariant block (rule 2) is evaluated the first time it is reached and
|
|
496
|
+
// its value reused, by reference, for every other enclosing row.
|
|
497
|
+
private memoized<T>(op: OpNode, scope: Scope, compute: () => T): T {
|
|
498
|
+
const memo = scope.run.memo;
|
|
499
|
+
if (memo.has(op)) { scope.run.trace?.({ kind: "memo", op }); return memo.get(op) as T; }
|
|
500
|
+
const value = compute();
|
|
501
|
+
memo.set(op, value);
|
|
502
|
+
return value;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// A block's rows and the predicate left to evaluate over each. A correlated
|
|
506
|
+
// plan is served, in order of preference, by a context index that yields rows
|
|
507
|
+
// (`lookupRows` — asked on every sight: it costs nothing and the collection
|
|
508
|
+
// is never read), by a probe on the engine's own index (built on the
|
|
509
|
+
// collection's second sight, or at once for a statically stable receiver: a
|
|
510
|
+
// collection seen once is cheaper to scan than to index), else by the scan.
|
|
511
|
+
// A statically stable receiver (rule 1b) is evaluated once per run; any other
|
|
512
|
+
// is keyed by its value's identity, so a fresh array per row is never indexed.
|
|
513
|
+
private access(op: OpNode, plan: BlockPlan, scope: Scope): Access {
|
|
514
|
+
const run = scope.run;
|
|
515
|
+
let state = plan.receiverStable ? run.stableCollections.get(op) : undefined;
|
|
516
|
+
if (!state) {
|
|
517
|
+
const value = this.evalRaw(op.receiver, scope);
|
|
518
|
+
if (plan.receiverStable) run.stableCollections.set(op, (state = newCollection(value)));
|
|
519
|
+
else if (plan.correlated.length === 0) return this.scan(this.rowsOf(value), op, scope);
|
|
520
|
+
else if (value !== null && typeof value === "object") {
|
|
521
|
+
state = run.collections.get(value);
|
|
522
|
+
if (!state) run.collections.set(value, (state = newCollection(value)));
|
|
523
|
+
} else {
|
|
524
|
+
run.trace?.({ kind: "fallback", reason: "not-a-collection" });
|
|
525
|
+
return this.scan(this.rowsOf(value), op, scope);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
state.seen++;
|
|
529
|
+
if (plan.correlated.length > 0) {
|
|
530
|
+
const direct = this.lookupRows(plan, state, scope);
|
|
531
|
+
if (direct) return direct;
|
|
532
|
+
if (state.seen >= 2 || plan.receiverStable) {
|
|
533
|
+
const probed = this.probe(plan, state, scope);
|
|
534
|
+
if (probed) return { rows: probed, where: plan.residual };
|
|
535
|
+
} else run.trace?.({ kind: "fallback", reason: "unstable" });
|
|
536
|
+
}
|
|
537
|
+
return this.scan(this.materialize(state), op, scope);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The plain scan: the rows, re-projected by `from`, with the whole `where`.
|
|
541
|
+
private scan(rows: unknown[], op: OpNode, scope: Scope): Access {
|
|
542
|
+
return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
|
|
546
|
+
for (const proj of from) rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
|
|
547
|
+
return rows;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private materialize(state: CollectionState): unknown[] {
|
|
551
|
+
return (state.rows ??= this.rowsOf(state.value));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// A probe answered by a context index that yields rows directly, before the
|
|
555
|
+
// collection is materialized: one correlation is probed (`lookupOrder`) and
|
|
556
|
+
// every other conjunct stays in place per selected row (`residualWithout`).
|
|
557
|
+
private lookupRows(plan: BlockPlan, state: CollectionState, scope: Scope): Access | null {
|
|
558
|
+
for (const c of lookupOrder(plan)) {
|
|
559
|
+
const index = this.contextIndex(state, c);
|
|
560
|
+
if (!index?.lookupRows) continue;
|
|
561
|
+
const outer = this.outerValue(c, scope);
|
|
562
|
+
if (!outer) return null;
|
|
563
|
+
const rows = Array.from(index.lookupRows(outer.value));
|
|
564
|
+
scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
|
|
565
|
+
return { rows, where: residualWithout(plan, c) };
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The engine's probe: one bucket per correlation, intersected smallest-first;
|
|
571
|
+
// the candidates come back in receiver order. `null` (an index that could
|
|
572
|
+
// not be built, an outer value whose evaluation threw) leaves the scan in charge.
|
|
573
|
+
private probe(plan: BlockPlan, state: CollectionState, scope: Scope): unknown[] | null {
|
|
574
|
+
const run = scope.run;
|
|
575
|
+
const rows = this.materialize(state);
|
|
576
|
+
const buckets: (readonly number[])[] = [];
|
|
577
|
+
for (const c of plan.correlated) {
|
|
578
|
+
const index = this.indexFor(state, c, scope);
|
|
579
|
+
if (!index) { run.trace?.({ kind: "fallback", reason: "index" }); return null; }
|
|
580
|
+
const outer = this.outerValue(c, scope);
|
|
581
|
+
if (!outer) return null;
|
|
582
|
+
buckets.push(index.lookup(outer.value));
|
|
583
|
+
}
|
|
584
|
+
buckets.sort((a, b) => a.length - b.length);
|
|
585
|
+
let candidates = buckets[0]!;
|
|
586
|
+
for (let i = 1; i < buckets.length && candidates.length > 0; i++) candidates = intersectPositions(candidates, buckets[i]!);
|
|
587
|
+
run.trace?.({ kind: "probe", paths: plan.correlated.map((c) => c.path), candidates: candidates.length });
|
|
588
|
+
return candidates.map((i) => rows[i]);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// A correlation's outer side, read once in a row-less scope inside the block
|
|
592
|
+
// (as a bound is); `null` when it threw — the scan then raises it in place.
|
|
593
|
+
private outerValue(c: Correlation, scope: Scope): { value: unknown } | null {
|
|
594
|
+
try { return { value: this.evalExpr(c.outer, rowless(scope)) }; }
|
|
595
|
+
catch { scope.run.trace?.({ kind: "fallback", reason: "probe-value" }); return null; }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// The context's pre-built index on a correlation's local path, if it offers
|
|
599
|
+
// one (asked once per collection and path; a refusal is cached too).
|
|
600
|
+
private contextIndex(state: CollectionState, c: Correlation): RowIndex | null {
|
|
601
|
+
const key = pathKey(c.path);
|
|
602
|
+
let index = state.contextIndexes.get(key);
|
|
603
|
+
if (index === undefined) state.contextIndexes.set(key, (index = this.ctx.indexFor?.(state.value, c.path) ?? null));
|
|
604
|
+
return index;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// The index serving a correlation's local path: the context's, else one built
|
|
608
|
+
// here by evaluating the local expression in each row's scope — the same read
|
|
609
|
+
// the scan performs, so the keys are exactly what `==` would compare. A throw
|
|
610
|
+
// while building (a context that rejects a read) leaves the path un-indexed.
|
|
611
|
+
private indexFor(state: CollectionState, c: Correlation, scope: Scope): RowIndex | null {
|
|
612
|
+
const key = pathKey(c.path);
|
|
613
|
+
const hit = state.indexes.get(key);
|
|
614
|
+
if (hit !== undefined) return hit;
|
|
615
|
+
const rows = this.materialize(state);
|
|
616
|
+
let index = this.contextIndex(state, c);
|
|
617
|
+
let source: "context" | "engine" = "context";
|
|
618
|
+
if (!index) {
|
|
619
|
+
source = "engine";
|
|
620
|
+
const built = new HashIndex();
|
|
621
|
+
try {
|
|
622
|
+
for (let i = 0; i < rows.length; i++) built.add(this.evalExpr(c.local, this.enter(rows[i], scope)), i);
|
|
623
|
+
index = built;
|
|
624
|
+
} catch { /* un-indexed: the scan serves this path */ }
|
|
625
|
+
}
|
|
626
|
+
if (index) scope.run.trace?.({ kind: "index", path: c.path, rows: rows.length, source });
|
|
627
|
+
state.indexes.set(key, index);
|
|
628
|
+
return index;
|
|
378
629
|
}
|
|
379
630
|
|
|
380
631
|
// A select-position collect/first/single, optionally recursive via `follow`.
|
|
381
632
|
private evalCollectValue(op: OpNode, scope: Scope): unknown {
|
|
633
|
+
const plan = this.planOf(op, scope);
|
|
634
|
+
if (!plan.invariant) return this.evalCollectValuePlanned(op, plan, scope);
|
|
635
|
+
return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private evalCollectValuePlanned(op: OpNode, plan: BlockPlan, scope: Scope): unknown {
|
|
382
639
|
const sub = op.sub;
|
|
383
640
|
const bound = this.boundOf(sub, scope);
|
|
384
641
|
let scopes: Scope[];
|
|
385
642
|
if (sub.follow) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
643
|
+
// The seeds are the matched rows themselves (`followWalk` enters them); a
|
|
644
|
+
// follow block is never correlated, so `where` here is the whole predicate.
|
|
645
|
+
const { rows, where } = this.access(op, plan, scope);
|
|
646
|
+
const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
|
|
389
647
|
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
|
|
390
648
|
this.sortScopes(scopes, sub.orderBy);
|
|
391
649
|
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
392
650
|
scopes = sliceBound(scopes, bound);
|
|
393
651
|
} else {
|
|
394
|
-
scopes = this.opRows(op, scope, bound);
|
|
652
|
+
scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
|
|
395
653
|
}
|
|
396
654
|
switch (op.op) {
|
|
397
655
|
case "collect": return scopes.map((s) => this.projectRow(sub, s));
|
|
@@ -429,7 +687,19 @@ export class InMemoryEngine implements Engine {
|
|
|
429
687
|
|
|
430
688
|
// ---- scalar expression evaluation -----------------------------------------
|
|
431
689
|
|
|
690
|
+
// An expression as a VALUE: whatever `evalRaw` yields, with a context's lazy
|
|
691
|
+
// stand-in resolved (`DataContext.materialize`), so no operand, argument, key
|
|
692
|
+
// or projected item ever observes a value the context meant as a collection
|
|
693
|
+
// it had not read yet.
|
|
432
694
|
private evalExpr(e: Expr, scope: Scope): unknown {
|
|
695
|
+
const v = this.evalRaw(e, scope);
|
|
696
|
+
return this.ctx.materialize ? this.ctx.materialize(v) : v;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// An expression as the context handed it out — for ROW POSITION only (the
|
|
700
|
+
// source, a receiver, a `from`, a `follow` destination), where the value goes
|
|
701
|
+
// straight to `toRows` / `indexFor`. Operands inside it are values (`evalExpr`).
|
|
702
|
+
private evalRaw(e: Expr, scope: Scope): unknown {
|
|
433
703
|
switch (e.kind) {
|
|
434
704
|
case "lit": return e.value;
|
|
435
705
|
case "binding":
|
|
@@ -523,8 +793,32 @@ export class InMemoryEngine implements Engine {
|
|
|
523
793
|
|
|
524
794
|
interface Occurrence { row: unknown; meta: Record<string, unknown>; }
|
|
525
795
|
|
|
796
|
+
// A block's accessed rows and the predicate still to evaluate over each.
|
|
797
|
+
interface Access { rows: unknown[]; where: Where | null; }
|
|
798
|
+
|
|
526
799
|
// ---- free helpers -----------------------------------------------------------
|
|
527
800
|
|
|
801
|
+
function newCollection(value: unknown): CollectionState {
|
|
802
|
+
return { value, seen: 0, rows: null, indexes: new Map(), contextIndexes: new Map() };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function pathKey(path: readonly string[]): string {
|
|
806
|
+
return JSON.stringify(path);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// A row-less scope INSIDE a block: a bare name is absent, `^name` is the
|
|
810
|
+
// enclosing row — how a block's bound and a probe's outer value are read.
|
|
811
|
+
function rowless(enclosing: Scope): Scope {
|
|
812
|
+
return { row: undefined, parent: enclosing, bindings: enclosing.bindings, run: enclosing.run };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// How many scopes out the root is (the root itself is 0).
|
|
816
|
+
function scopeDepth(scope: Scope): number {
|
|
817
|
+
let d = 0;
|
|
818
|
+
for (let s = scope; s.parent !== null; s = s.parent) d++;
|
|
819
|
+
return d;
|
|
820
|
+
}
|
|
821
|
+
|
|
528
822
|
function isRelOp(op: string): boolean {
|
|
529
823
|
return op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
530
824
|
}
|
package/src/index.ts
CHANGED
|
@@ -30,9 +30,14 @@ export { OqxError } from "./errors.ts";
|
|
|
30
30
|
|
|
31
31
|
/** The OQX spec version (`spec/oqx/VERSION`, `major.minor`) this implementation
|
|
32
32
|
* conforms to — distinct from the package's own semver. */
|
|
33
|
-
export const LANGUAGE_VERSION = "0.
|
|
34
|
-
export type { OqxResult, Engine } from "./engine.ts";
|
|
33
|
+
export const LANGUAGE_VERSION = "0.14";
|
|
34
|
+
export type { OqxResult, Engine, InMemoryEngineOptions, TraceEvent } from "./engine.ts";
|
|
35
35
|
export { InMemoryEngine, runQuery } from "./engine.ts";
|
|
36
|
+
export type { BlockPlan, Correlation, Rule, RuleContext, RowIndex } from "./optimize/index.ts";
|
|
37
|
+
export {
|
|
38
|
+
DEFAULT_RULES, correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly,
|
|
39
|
+
optimizeBlock, logicalBlock, HashIndex,
|
|
40
|
+
} from "./optimize/index.ts";
|
|
36
41
|
export type { DataContext, CallResult, DefaultContextOptions } from "./context.ts";
|
|
37
42
|
export { DefaultContext } from "./context.ts";
|
|
38
43
|
export type { RegexDialect, RegexFlags } from "./regex.ts";
|