@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +162 -24
  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/ast.d.ts +1 -1
  8. package/dist/ast.d.ts.map +1 -1
  9. package/dist/context.d.ts +24 -0
  10. package/dist/context.d.ts.map +1 -1
  11. package/dist/context.js.map +1 -1
  12. package/dist/engine.d.ts +55 -2
  13. package/dist/engine.d.ts.map +1 -1
  14. package/dist/engine.js +272 -39
  15. package/dist/engine.js.map +1 -1
  16. package/dist/index.d.ts +4 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/optimize/analysis.d.ts +45 -0
  21. package/dist/optimize/analysis.d.ts.map +1 -0
  22. package/dist/optimize/analysis.js +194 -0
  23. package/dist/optimize/analysis.js.map +1 -0
  24. package/dist/optimize/driver.d.ts +8 -0
  25. package/dist/optimize/driver.d.ts.map +1 -0
  26. package/dist/optimize/driver.js +35 -0
  27. package/dist/optimize/driver.js.map +1 -0
  28. package/dist/optimize/hash-index.d.ts +34 -0
  29. package/dist/optimize/hash-index.d.ts.map +1 -0
  30. package/dist/optimize/hash-index.js +79 -0
  31. package/dist/optimize/hash-index.js.map +1 -0
  32. package/dist/optimize/index.d.ts +8 -0
  33. package/dist/optimize/index.d.ts.map +1 -0
  34. package/dist/optimize/index.js +11 -0
  35. package/dist/optimize/index.js.map +1 -0
  36. package/dist/optimize/ir.d.ts +84 -0
  37. package/dist/optimize/ir.d.ts.map +1 -0
  38. package/dist/optimize/ir.js +60 -0
  39. package/dist/optimize/ir.js.map +1 -0
  40. package/dist/optimize/rules.d.ts +8 -0
  41. package/dist/optimize/rules.d.ts.map +1 -0
  42. package/dist/optimize/rules.js +136 -0
  43. package/dist/optimize/rules.js.map +1 -0
  44. package/dist/plan.js +1 -1
  45. package/dist/plan.js.map +1 -1
  46. package/dist/semantics.js +1 -1
  47. package/dist/semantics.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/adapters/indexed.ts +60 -24
  50. package/src/ast.ts +2 -2
  51. package/src/context.ts +24 -0
  52. package/src/engine.ts +307 -37
  53. package/src/index.ts +7 -2
  54. package/src/optimize/analysis.ts +183 -0
  55. package/src/optimize/driver.ts +37 -0
  56. package/src/optimize/hash-index.ts +91 -0
  57. package/src/optimize/index.ts +16 -0
  58. package/src/optimize/ir.ts +128 -0
  59. package/src/optimize/rules.ts +136 -0
  60. package/src/plan.ts +1 -1
  61. package/src/semantics.ts +1 -1
@@ -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/ast.ts CHANGED
@@ -14,7 +14,7 @@ export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
14
14
  /** Scalar value/predicate expression, evaluated against a row scope + bindings. */
15
15
  export type Expr =
16
16
  | { kind: "lit"; value: string | number | boolean | null }
17
- | { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs); `$value` is the row itself
17
+ | { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs); `$it` is the row itself
18
18
  | { kind: "outer"; levels: number; name: string } // `^name` — read from exactly `levels` scopes out
19
19
  | { kind: "binding"; index: number } // a ${…} interpolated host value
20
20
  | { kind: "member"; recv: Expr; name: string } // .prop navigation on the value to its left
@@ -81,7 +81,7 @@ export interface Subquery {
81
81
  follow: Follow | null;
82
82
  /** `values` — scalar projection mode: the (single) projected expression is the
83
83
  * row's result itself rather than being wrapped in a `{ name: value }` record,
84
- * so `name values` yields `["Bob", …]` and `$value values` yields the rows. */
84
+ * so `name values` yields `["Bob", …]` and `$it values` yields the rows. */
85
85
  values?: boolean;
86
86
  /** `limit N` / `offset N` — bound the row set AFTER where/order/distinct and
87
87
  * BEFORE the consumer reduces it, so `count { … limit 5 }` is at most 5 and
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 {