@omgbase/oqx 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/README.md +162 -24
- 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 +1 -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 +272 -39
- 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/plan.js +1 -1
- package/dist/plan.js.map +1 -1
- package/dist/semantics.js +1 -1
- package/dist/semantics.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/indexed.ts +60 -24
- package/src/ast.ts +2 -2
- package/src/context.ts +24 -0
- package/src/engine.ts +307 -37
- 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/plan.ts +1 -1
- package/src/semantics.ts +1 -1
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);
|
|
@@ -150,12 +216,12 @@ export class InMemoryEngine implements Engine {
|
|
|
150
216
|
}
|
|
151
217
|
|
|
152
218
|
// Make the scope for a row. An `entries()` entry is unwrapped here: the scope's
|
|
153
|
-
// row is the property's VALUE (so `$
|
|
219
|
+
// row is the property's VALUE (so `$it` and bare names read it) and the key
|
|
154
220
|
// becomes the `$key` intrinsic in `meta`. Every place a row becomes a scope
|
|
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)) {
|
|
@@ -232,7 +298,7 @@ export class InMemoryEngine implements Engine {
|
|
|
232
298
|
const rowScope = scopeFor(row);
|
|
233
299
|
const raw: unknown[] = [];
|
|
234
300
|
for (const dest of follow.destinations) {
|
|
235
|
-
const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.
|
|
301
|
+
const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
|
|
236
302
|
raw.push(...this.rowsOf(v));
|
|
237
303
|
}
|
|
238
304
|
const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where!, this.enter(x, rowScope)))) : raw;
|
|
@@ -344,12 +410,28 @@ export class InMemoryEngine implements Engine {
|
|
|
344
410
|
|
|
345
411
|
private evalWhereOp(op: OpNode, scope: Scope): boolean {
|
|
346
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 {
|
|
347
419
|
const bound = this.boundOf(op.sub, scope);
|
|
348
|
-
if (op.op === "exists" || op.op === "none") {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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;
|
|
353
435
|
}
|
|
354
436
|
if (op.op === "collect") {
|
|
355
437
|
const matched = this.opRows(op, scope, bound);
|
|
@@ -367,55 +449,207 @@ export class InMemoryEngine implements Engine {
|
|
|
367
449
|
}
|
|
368
450
|
return matched.length > 0;
|
|
369
451
|
}
|
|
370
|
-
if (op.op === "count") {
|
|
371
|
-
const n = this.opRows(op, scope, bound).length;
|
|
372
|
-
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
373
|
-
}
|
|
374
452
|
return this.opRows(op, scope, bound).length > 0;
|
|
375
453
|
}
|
|
376
454
|
|
|
377
455
|
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
378
|
-
private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
|
|
379
|
-
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);
|
|
380
458
|
this.sortScopes(scopes, op.sub.orderBy);
|
|
381
459
|
if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
|
|
382
460
|
return sliceBound(scopes, bound);
|
|
383
461
|
}
|
|
384
462
|
|
|
385
|
-
// Short-circuiting existence check over a
|
|
386
|
-
|
|
387
|
-
|
|
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; }
|
|
388
467
|
return false;
|
|
389
468
|
}
|
|
390
469
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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);
|
|
394
474
|
for (const r of rows) {
|
|
395
475
|
const s = this.enter(r, scope, { lifts: {} });
|
|
396
|
-
if (!
|
|
476
|
+
if (!where || this.evalWhere(where, s)) yield s;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
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" });
|
|
397
536
|
}
|
|
537
|
+
return this.scan(this.materialize(state), op, scope);
|
|
398
538
|
}
|
|
399
539
|
|
|
400
|
-
|
|
401
|
-
|
|
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;
|
|
402
629
|
}
|
|
403
630
|
|
|
404
631
|
// A select-position collect/first/single, optionally recursive via `follow`.
|
|
405
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 {
|
|
406
639
|
const sub = op.sub;
|
|
407
640
|
const bound = this.boundOf(sub, scope);
|
|
408
641
|
let scopes: Scope[];
|
|
409
642
|
if (sub.follow) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
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;
|
|
413
647
|
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
|
|
414
648
|
this.sortScopes(scopes, sub.orderBy);
|
|
415
649
|
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
416
650
|
scopes = sliceBound(scopes, bound);
|
|
417
651
|
} else {
|
|
418
|
-
scopes = this.opRows(op, scope, bound);
|
|
652
|
+
scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
|
|
419
653
|
}
|
|
420
654
|
switch (op.op) {
|
|
421
655
|
case "collect": return scopes.map((s) => this.projectRow(sub, s));
|
|
@@ -453,7 +687,19 @@ export class InMemoryEngine implements Engine {
|
|
|
453
687
|
|
|
454
688
|
// ---- scalar expression evaluation -----------------------------------------
|
|
455
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.
|
|
456
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 {
|
|
457
703
|
switch (e.kind) {
|
|
458
704
|
case "lit": return e.value;
|
|
459
705
|
case "binding":
|
|
@@ -503,9 +749,9 @@ export class InMemoryEngine implements Engine {
|
|
|
503
749
|
}
|
|
504
750
|
|
|
505
751
|
// Resolve a name against ONE scope — never its ancestors. A scope provides,
|
|
506
|
-
// in order: `$
|
|
752
|
+
// in order: `$it` (the scope's row itself — the current item, whatever its
|
|
507
753
|
// type, so scalar collections are queryable; absent at the root, which has no
|
|
508
|
-
// row); `$key` (the property key) when it is an entry scope — see `enter`;
|
|
754
|
+
// row; `$value`, its pre-0.15 spelling, is an ordinary property name now); `$key` (the property key) when it is an entry scope — see `enter`;
|
|
509
755
|
// the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
|
|
510
756
|
// values lifted into it by `^name:` items; then either the row's own property
|
|
511
757
|
// or, for the root scope (no row), the context's named roots.
|
|
@@ -524,7 +770,7 @@ export class InMemoryEngine implements Engine {
|
|
|
524
770
|
// always spelled explicitly as `^name`. Present-but-falsy values (null, false,
|
|
525
771
|
// 0, "") need no special case — there is no "absent, so look outward" rule.
|
|
526
772
|
private resolveIn(name: string, scope: Scope): unknown {
|
|
527
|
-
if (name === "$
|
|
773
|
+
if (name === "$it") return scope.parent === null ? undefined : scope.row;
|
|
528
774
|
if ((name === KEY || RECUR.has(name)) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
|
|
529
775
|
if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
|
|
530
776
|
if (scope.parent === null) return this.ctx.root(name);
|
|
@@ -547,8 +793,32 @@ export class InMemoryEngine implements Engine {
|
|
|
547
793
|
|
|
548
794
|
interface Occurrence { row: unknown; meta: Record<string, unknown>; }
|
|
549
795
|
|
|
796
|
+
// A block's accessed rows and the predicate still to evaluate over each.
|
|
797
|
+
interface Access { rows: unknown[]; where: Where | null; }
|
|
798
|
+
|
|
550
799
|
// ---- free helpers -----------------------------------------------------------
|
|
551
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
|
+
|
|
552
822
|
function isRelOp(op: string): boolean {
|
|
553
823
|
return op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
554
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.15";
|
|
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";
|