@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
@@ -0,0 +1,8 @@
1
+ import type { Rule } from "./ir.js";
2
+ export declare const correlatedEqualityProbe: Rule;
3
+ export declare const stableReceiver: Rule;
4
+ export declare const invariantBlock: Rule;
5
+ export declare const cardinalityOnly: Rule;
6
+ /** The rules the engine applies by default, in order. */
7
+ export declare const DEFAULT_RULES: readonly Rule[];
8
+ //# sourceMappingURL=rules.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules.d.ts","sourceRoot":"","sources":["../../src/optimize/rules.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAe,IAAI,EAAe,MAAM,SAAS,CAAC;AA2B9D,eAAO,MAAM,uBAAuB,EAAE,IAgBrC,CAAC;AA6BF,eAAO,MAAM,cAAc,EAAE,IAI5B,CAAC;AAiBF,eAAO,MAAM,cAAc,EAAE,IAK5B,CAAC;AAkBF,eAAO,MAAM,eAAe,EAAE,IAM7B,CAAC;AAEF,yDAAyD;AACzD,eAAO,MAAM,aAAa,EAAE,SAAS,IAAI,EAA+E,CAAC"}
@@ -0,0 +1,136 @@
1
+ // The rule catalog. Each rule states its precondition and the argument that the
2
+ // rewritten plan is indistinguishable from the scan it replaces — same rows in
3
+ // the same order, same lifts, same error (same message) at the same point, or
4
+ // no error — under SEMANTICS §13's strict left-to-right `&&`.
5
+ import { exprRaiseFree, exprReadsCurrentScope, exprReadsScopeIn, localPath, opHasLifts, opReadsScopeIn, whereHasLifts, whereRaiseFree, } from "./analysis.js";
6
+ import { conjunction, conjuncts } from "./ir.js";
7
+ // ---- rule 1: correlated equality → hash probe ------------------------------------
8
+ //
9
+ // Precondition. The block has no `from` re-projection (its rows ARE the
10
+ // receiver's rows) and no `follow`; its `where` has no lift anywhere (a lift
11
+ // into an enclosing scope by one row could change what the outer side reads
12
+ // for the next row); some top-level conjunct k is `local == outer` (either
13
+ // side) with `local` an identifier/member chain on the block's row and `outer`
14
+ // a raise-free expression reading nothing from the block's row; and every
15
+ // conjunct left of k is raise-free (no call, no `single`, no lift, no bound, no
16
+ // binding past the run's count).
17
+ //
18
+ // Argument. The scan evaluates, per receiver row r in order, c1(r) && … &&
19
+ // ck(r) && …, short-circuiting. The probe selects exactly the rows with
20
+ // local(r) == outer (the index reproduces §5 equality, `outer` is evaluated
21
+ // once because nothing in the block can change what it reads) and evaluates the
22
+ // residual conjunction over them, in receiver order. For a selected row every
23
+ // conjunct is evaluated as the scan would (ck is dropped: it is true by
24
+ // construction and, being two reads, cannot raise or bind). For a row the probe
25
+ // rejects, the scan would have evaluated c1..c(k-1) — which cannot raise or
26
+ // bind — then ck, false, and stopped: no result, no error, no lift. So the two
27
+ // paths keep the same rows, raise the same first error, and bind the same
28
+ // lifts. A probe that cannot be built at run time (a receiver that is not
29
+ // stable, an index whose construction throws) falls back to the scan itself.
30
+ export const correlatedEqualityProbe = (plan, ctx) => {
31
+ if (plan.correlated.length > 0 || plan.from.length > 0 || plan.follow || !plan.where)
32
+ return null;
33
+ if (whereHasLifts(plan.where))
34
+ return null;
35
+ const correlated = [];
36
+ const residual = [];
37
+ let prefixRaiseFree = true;
38
+ conjuncts(plan.where).forEach((part, index) => {
39
+ const eq = prefixRaiseFree ? asCorrelation(part, index, ctx) : null;
40
+ if (eq)
41
+ correlated.push(eq);
42
+ else {
43
+ residual.push(part);
44
+ prefixRaiseFree &&= whereRaiseFree(part, ctx);
45
+ }
46
+ });
47
+ if (correlated.length === 0)
48
+ return null;
49
+ return { ...plan, correlated, residual: conjunction(residual) };
50
+ };
51
+ /** `local == outer` / `outer == local` as a correlation, else null. */
52
+ function asCorrelation(w, index, ctx) {
53
+ if (w.kind !== "scalar" || w.expr.kind !== "binary" || w.expr.op !== "==")
54
+ return null;
55
+ const { left, right } = w.expr;
56
+ return pair(left, right, index, ctx) ?? pair(right, left, index, ctx);
57
+ }
58
+ function pair(local, outer, index, ctx) {
59
+ const path = localPath(local);
60
+ if (path === null)
61
+ return null;
62
+ if (exprReadsCurrentScope(outer) || !exprRaiseFree(outer, ctx))
63
+ return null;
64
+ return { local, outer, path, index };
65
+ }
66
+ // ---- rule 1b: a receiver stable across enclosing rows ----------------------------
67
+ //
68
+ // Precondition. The receiver expression, read in the enclosing scope (depth
69
+ // `plan.depth`), reads no scope at depth 1..depth: only the root (named roots,
70
+ // `^…^name` reaching it, or past it) and bindings.
71
+ //
72
+ // Argument. Such an expression yields the same value for every enclosing row
73
+ // (evaluation is pure; the root and the bindings do not change within a run),
74
+ // so its rows can be materialized once per run and indexed once per path. A
75
+ // receiver that does read an enclosing row is still indexable when it happens
76
+ // to evaluate to the same object twice (the engine keys indexes by collection
77
+ // identity as well); a receiver that yields a fresh value per row is scanned.
78
+ export const stableReceiver = (plan) => {
79
+ if (plan.receiverStable)
80
+ return null;
81
+ if (exprReadsScopeIn(plan.receiver, plan.depth, plan.depth))
82
+ return null;
83
+ return { ...plan, receiverStable: true };
84
+ };
85
+ // ---- rule 2: invariant nested block → evaluate once per run ----------------------
86
+ //
87
+ // Precondition. Nothing in the block — receiver, `from`, `where`, `select`,
88
+ // `order by`, bound, `follow` — reads a scope at depth 1..depth (an enclosing
89
+ // row), and the block lifts nothing (a lift is a side effect on an enclosing
90
+ // scope that must recur per row).
91
+ //
92
+ // Argument. The block's value is a function of the root scope, the bindings
93
+ // and its own rows only, all constant within a run, so every evaluation yields
94
+ // the same value or raises the same error. The engine evaluates it the first
95
+ // time the scan would (so an error surfaces at the same point) and returns the
96
+ // memoized value afterwards; a run that raised never reaches a second
97
+ // evaluation. The memoized value is shared by reference across the enclosing
98
+ // rows that project it.
99
+ export const invariantBlock = (plan) => {
100
+ if (plan.invariant)
101
+ return null;
102
+ if (opHasLifts(plan.node))
103
+ return null;
104
+ if (opReadsScopeIn(plan.node, plan.depth, plan.depth))
105
+ return null;
106
+ return { ...plan, invariant: true };
107
+ };
108
+ // ---- rule 3: exists / none / count from cardinality -------------------------------
109
+ //
110
+ // Precondition. A where-position `exists`, `none` or `count` without `distinct`
111
+ // (dedup projects every row), without `order by` (`count` evaluates the keys),
112
+ // without `follow`, and with nothing left to evaluate per row: either the block
113
+ // has no `where`, or every conjunct is correlated (so the probe's bucket is the
114
+ // matched set).
115
+ //
116
+ // Argument. With no residual predicate the matched rows are exactly the
117
+ // accessed rows (§13: the receiver's rows filtered by `where`), entering a row
118
+ // as a scope cannot raise, and the consumers only need the count after the
119
+ // bound (§18): `count` is `boundedCount(n)`, `exists` is `boundedCount(n) > 0`,
120
+ // `none` its negation — what the scan computes after materializing. When the
121
+ // probe is unavailable at run time the residual is the whole `where` and the
122
+ // engine takes the per-row path.
123
+ export const cardinalityOnly = (plan) => {
124
+ if (plan.fromCardinality)
125
+ return null;
126
+ if (plan.consumer !== "exists" && plan.consumer !== "none" && plan.consumer !== "count")
127
+ return null;
128
+ if (plan.distinct || plan.orderBy || plan.follow)
129
+ return null;
130
+ if (plan.where !== null && plan.residual !== null)
131
+ return null;
132
+ return { ...plan, fromCardinality: true };
133
+ };
134
+ /** The rules the engine applies by default, in order. */
135
+ export const DEFAULT_RULES = [correlatedEqualityProbe, stableReceiver, invariantBlock, cardinalityOnly];
136
+ //# sourceMappingURL=rules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules.js","sourceRoot":"","sources":["../../src/optimize/rules.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/oqx",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "OQX (omgbase Query eXpressions): a composable query language and engine for JavaScript collections",
5
5
  "keywords": [
6
6
  "query",
@@ -1,38 +1,66 @@
1
1
  // An in-memory optimizing planner: it hash-indexes a named root collection on
2
- // chosen fields and, for a query whose where-clause contains equality predicates
3
- // on those fields, answers from the index (candidate intersection) instead of a
4
- // full scan. Everything it can't turn into an index probe is left as a residual
5
- // query the in-memory engine finishes over the candidate rows.
2
+ // chosen fields and, for a query whose where-clause contains equality
3
+ // predicates on those fields, answers from the index (candidate intersection)
4
+ // instead of a full scan. Everything it can't turn into an index probe is left
5
+ // as a residual query the in-memory engine finishes over the candidate rows.
6
6
  //
7
7
  // This is the smallest honest demonstration of plan optimization: same answers
8
8
  // as a naive scan, far fewer rows examined, and partial-pushdown correctness via
9
9
  // the residual.
10
+ //
11
+ // The same indexes serve the engine's correlated probes: `context()` is a
12
+ // `DataContext` that resolves the root and answers `indexFor(collection, path)`
13
+ // from them, so a nested `^emp first { where id == ^manager_id }` probes the
14
+ // pre-built index instead of building one per run. The planner's own plans
15
+ // carry that context too, so a residual's nested blocks still see every root.
16
+ //
17
+ // Buckets are keyed under OQX equality (`HashIndex`): absent ≡ null, `-0` ≡ `0`,
18
+ // `NaN` matches nothing, objects by reference — exactly what `==` compares, so
19
+ // a probe returns the rows the scan would keep (a plain `Map` would keep `null`
20
+ // and `undefined` apart and match `NaN` to itself).
10
21
 
11
22
  import type { Query } from "../ast.ts";
23
+ import type { DataContext } from "../context.ts";
24
+ import { DefaultContext } from "../context.ts";
12
25
  import type { Plan, QueryPlanner } from "../planner.ts";
13
- import { partitionPushable, residualQuery, asEquality, constValue } from "../plan.ts";
26
+ import { partitionPushable, residualQuery, asEquality, constValue, ROWS_ROOT } from "../plan.ts";
27
+ import type { RowIndex } from "../optimize/hash-index.ts";
28
+ import { HashIndex, intersectPositions } from "../optimize/hash-index.ts";
14
29
 
15
30
  export class IndexedCollection implements QueryPlanner {
16
31
  private name: string;
17
32
  private rows: readonly unknown[];
18
- private indexes = new Map<string, Map<unknown, unknown[]>>();
33
+ private indexes = new Map<string, HashIndex>();
19
34
 
20
- /** Index `rows` (exposed as root `name`) on each field in `indexFields`. */
35
+ /** Index `rows` (exposed as root `name`) on each field in `indexFields`. A
36
+ * field is read as the engine reads a bare identifier (own property; array
37
+ * element for an integer-spelled name); a row without it keys as absent. */
21
38
  constructor(name: string, rows: readonly unknown[], indexFields: readonly string[]) {
22
39
  this.name = name;
23
40
  this.rows = rows;
41
+ const read = new DefaultContext();
24
42
  for (const field of indexFields) {
25
- const idx = new Map<unknown, unknown[]>();
26
- for (const row of rows) {
27
- const key = (row as Record<string, unknown>)?.[field];
28
- let bucket = idx.get(key);
29
- if (!bucket) idx.set(key, (bucket = []));
30
- bucket.push(row);
31
- }
43
+ const idx = new HashIndex();
44
+ rows.forEach((row, i) => idx.add(read.get(row, field), i));
32
45
  this.indexes.set(field, idx);
33
46
  }
34
47
  }
35
48
 
49
+ /** The pre-built index for `collection` on `path` when `collection` is this
50
+ * collection's rows and `path` is one indexed field — the `DataContext.indexFor`
51
+ * seam, which `context()` wires up. */
52
+ indexFor(collection: unknown, path: readonly string[]): RowIndex | undefined {
53
+ if (collection !== this.rows || path.length !== 1) return undefined;
54
+ return this.indexes.get(path[0]!);
55
+ }
56
+
57
+ /** A `DataContext` over plain objects that serves this collection as the
58
+ * root `name` (plus `extraRoots`) and exposes the indexes through
59
+ * `indexFor`, so the engine's correlated probes reuse them. */
60
+ context(extraRoots: Record<string, unknown> = {}): DataContext {
61
+ return new IndexedContext(this, { ...extraRoots, [this.name]: this.rows });
62
+ }
63
+
36
64
  plan(query: Query, params: readonly unknown[]): Plan | null {
37
65
  if (query.source.kind !== "ident" || query.source.name !== this.name) return null;
38
66
  if (query.from.length > 0 || query.follow) return null;
@@ -44,18 +72,26 @@ export class IndexedCollection implements QueryPlanner {
44
72
  if (pushed.length === 0) return null; // no index probe available — let the scan handle it
45
73
 
46
74
  // Intersect the candidate sets from each indexed equality (smallest first).
47
- let candidate: unknown[] | null = null;
48
- for (const e of pushed) {
75
+ const buckets = pushed.map((e) => {
49
76
  const eq = asEquality(e)!;
50
- const bucket = this.indexes.get(eq.field)!.get(constValue(eq.value, params)) ?? [];
51
- candidate = candidate === null ? bucket.slice() : intersect(candidate, bucket);
52
- }
53
- const rows = candidate ?? [];
54
- return { rows: () => rows, residual: residualQuery(query, residual) };
77
+ return this.indexes.get(eq.field)!.lookup(constValue(eq.value, params));
78
+ }).sort((a, b) => a.length - b.length);
79
+ let candidate = buckets[0]!;
80
+ for (let i = 1; i < buckets.length && candidate.length > 0; i++) candidate = intersectPositions(candidate, buckets[i]!);
81
+ const rows = candidate.map((i) => this.rows[i]);
82
+ return { rows: () => rows, residual: residualQuery(query, residual), context: this.context({ [ROWS_ROOT]: rows }) };
55
83
  }
56
84
  }
57
85
 
58
- function intersect(a: unknown[], b: unknown[]): unknown[] {
59
- const set = new Set(b);
60
- return a.filter((x) => set.has(x));
86
+ class IndexedContext extends DefaultContext {
87
+ private owner: IndexedCollection;
88
+
89
+ constructor(owner: IndexedCollection, roots: Record<string, unknown>) {
90
+ super(roots);
91
+ this.owner = owner;
92
+ }
93
+
94
+ indexFor(collection: unknown, path: readonly string[]): RowIndex | undefined {
95
+ return this.owner.indexFor(collection, path);
96
+ }
61
97
  }
package/src/context.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { coerceCollection, regexMatches, BUILTIN_FUNCTIONS, BUILTIN_METHODS } from "./semantics.ts";
12
12
  import type { RegexDialect } from "./regex.ts";
13
+ import type { RowIndex } from "./optimize/hash-index.ts";
13
14
 
14
15
  // An array's only properties are its integer indices, spelled canonically
15
16
  // (`"0"`, `"12"`; never `"01"`, `"length"`, or a method name).
@@ -36,6 +37,18 @@ export interface DataContext {
36
37
  get(row: unknown, key: string): unknown;
37
38
  /** Coerce a relation/source value into rows (may be lazy). */
38
39
  toRows(value: unknown): Iterable<unknown>;
40
+ /** Optional: a value this context handed out as a stand-in for a collection
41
+ * it has not read yet (a lazy table handle), resolved to what it stands for.
42
+ * The engine calls it on every value it is about to observe AS A VALUE — an
43
+ * operand of `==`/`in`/arithmetic, a function or method argument, a projected
44
+ * item, an `order by` or `distinct` key, a `where` scalar, a lift — and never
45
+ * on a value it reads in ROW POSITION (the query source, a block receiver, a
46
+ * body-level `from`, a `follow` destination), which reaches `toRows` and
47
+ * `indexFor` as handed out, so a store-backed context can answer a probe on
48
+ * the handle without reading the table and still never lets the stand-in be
49
+ * seen by the language. Absent = identity; a context whose handle already
50
+ * behaves as the collection (a `Proxy` over an array) needs none. */
51
+ materialize?(value: unknown): unknown;
39
52
  /** Identity of a row for `follow` cycle detection / dedup. */
40
53
  identity(row: unknown): unknown;
41
54
  /** Optional custom free function; return `{ handled: false }` to defer. */
@@ -50,6 +63,17 @@ export interface DataContext {
50
63
  * `regexMatches(recv, args, dialect)` is the helper). `BUILTIN_METHODS.matches`
51
64
  * is always the baseline. */
52
65
  readonly regexDialect?: RegexDialect;
66
+ /** Optional: a pre-built equality index over `collection` (a value this
67
+ * context served as a root or relation) on the property path `path`
68
+ * (`["customer_id"]`, `["meta", "id"]`; `[]` keys by the row itself). The
69
+ * engine asks before building its own hash index for a correlated equality
70
+ * in a nested block (`where id == ^customer_id`); return `undefined` to let
71
+ * it build one. `lookup(value)` must return the ascending positions, into
72
+ * `toRows(collection)` in order, of the rows whose value at `path` equals
73
+ * `value` under OQX equality (SEMANTICS §5: absent ≡ null, `-0` ≡ `0`, `NaN`
74
+ * matches nothing, objects by reference). `IndexedCollection.context()`
75
+ * implements it over its indexes. */
76
+ indexFor?(collection: unknown, path: readonly string[]): RowIndex | undefined;
53
77
  }
54
78
 
55
79
  export interface DefaultContextOptions {