@omgbase/oqx 0.14.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +139 -6
  3. package/dist/adapters/indexed.d.ts +13 -1
  4. package/dist/adapters/indexed.d.ts.map +1 -1
  5. package/dist/adapters/indexed.js +55 -24
  6. package/dist/adapters/indexed.js.map +1 -1
  7. package/dist/context.d.ts +24 -0
  8. package/dist/context.d.ts.map +1 -1
  9. package/dist/context.js.map +1 -1
  10. package/dist/engine.d.ts +55 -2
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +268 -35
  13. package/dist/engine.js.map +1 -1
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/optimize/analysis.d.ts +45 -0
  19. package/dist/optimize/analysis.d.ts.map +1 -0
  20. package/dist/optimize/analysis.js +194 -0
  21. package/dist/optimize/analysis.js.map +1 -0
  22. package/dist/optimize/driver.d.ts +8 -0
  23. package/dist/optimize/driver.d.ts.map +1 -0
  24. package/dist/optimize/driver.js +35 -0
  25. package/dist/optimize/driver.js.map +1 -0
  26. package/dist/optimize/hash-index.d.ts +34 -0
  27. package/dist/optimize/hash-index.d.ts.map +1 -0
  28. package/dist/optimize/hash-index.js +79 -0
  29. package/dist/optimize/hash-index.js.map +1 -0
  30. package/dist/optimize/index.d.ts +8 -0
  31. package/dist/optimize/index.d.ts.map +1 -0
  32. package/dist/optimize/index.js +11 -0
  33. package/dist/optimize/index.js.map +1 -0
  34. package/dist/optimize/ir.d.ts +84 -0
  35. package/dist/optimize/ir.d.ts.map +1 -0
  36. package/dist/optimize/ir.js +60 -0
  37. package/dist/optimize/ir.js.map +1 -0
  38. package/dist/optimize/rules.d.ts +8 -0
  39. package/dist/optimize/rules.d.ts.map +1 -0
  40. package/dist/optimize/rules.js +136 -0
  41. package/dist/optimize/rules.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/adapters/indexed.ts +60 -24
  44. package/src/context.ts +24 -0
  45. package/src/engine.ts +303 -33
  46. package/src/index.ts +6 -1
  47. package/src/optimize/analysis.ts +183 -0
  48. package/src/optimize/driver.ts +37 -0
  49. package/src/optimize/hash-index.ts +91 -0
  50. package/src/optimize/index.ts +16 -0
  51. package/src/optimize/ir.ts +128 -0
  52. package/src/optimize/rules.ts +136 -0
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 root: Scope = { row: null, parent: null, bindings };
75
- let rows = this.rowsOf(this.evalExpr(query.source, root));
76
- for (const proj of query.from) {
77
- rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
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 : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
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)) {
@@ -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.evalExpr(dest, rowScope);
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
- // Unbounded: stop at the first match (dedup cannot change emptiness).
350
- // Bounded: the offset/limit decide emptiness, so materialize the set.
351
- const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
352
- return op.op === "exists" ? any : !any;
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 consumer receiver.
386
- private anyMatch(op: OpNode, scope: Scope): boolean {
387
- for (const s of this.iterMatchRows(op, scope)) { void s; return true; }
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
- private *iterMatchRows(op: OpNode, scope: Scope): Generator<Scope> {
392
- let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
393
- for (const proj of op.sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
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 (!op.sub.where || this.evalWhere(op.sub.where, s)) yield s;
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
- private matchRows(op: OpNode, scope: Scope): Scope[] {
401
- return Array.from(this.iterMatchRows(op, scope));
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
- let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
411
- for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
412
- const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, this.enter(r, scope, { lifts: {} }))) : rows;
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":
@@ -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
@@ -31,8 +31,13 @@ export { OqxError } from "./errors.ts";
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
33
  export const LANGUAGE_VERSION = "0.14";
34
- export type { OqxResult, Engine } from "./engine.ts";
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";
@@ -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
+ }