@omgbase/oqx 0.1.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/README.md +424 -0
- package/package.json +31 -0
- package/src/adapters/indexed.ts +61 -0
- package/src/adapters/sqlite.ts +120 -0
- package/src/ast.ts +87 -0
- package/src/context.ts +79 -0
- package/src/engine.ts +406 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +90 -0
- package/src/lexer.ts +175 -0
- package/src/parser.ts +517 -0
- package/src/plan.ts +58 -0
- package/src/planner.ts +47 -0
- package/src/semantics.ts +131 -0
package/src/ast.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// OQX query AST for the generic kernel. The parser produces this directly and
|
|
2
|
+
// the interpreter walks it — there is no separate lowering/IR pass, because a
|
|
3
|
+
// "relation" in the generic kernel is just an expression evaluated against the
|
|
4
|
+
// current row (property navigation on the host object model), resolved at run
|
|
5
|
+
// time rather than against a fixed schema.
|
|
6
|
+
|
|
7
|
+
/** Query consumers — how a (sub)query's row set is shaped. */
|
|
8
|
+
export type Consumer = "collect" | "exists" | "count" | "first" | "single";
|
|
9
|
+
|
|
10
|
+
/** Comparison operators usable in a `count { … } <op> <int>` test. */
|
|
11
|
+
export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
|
|
12
|
+
|
|
13
|
+
/** Scalar value/predicate expression, evaluated against a row scope + bindings. */
|
|
14
|
+
export type Expr =
|
|
15
|
+
| { kind: "lit"; value: string | number | boolean | null }
|
|
16
|
+
| { kind: "ident"; name: string } // bare property of the current row (climbs scopes)
|
|
17
|
+
| { kind: "outer"; levels: number; name: string } // `^name` — read `levels` scopes out
|
|
18
|
+
| { kind: "binding"; index: number } // a ${…} interpolated host value
|
|
19
|
+
| { kind: "member"; recv: Expr; name: string } // .prop navigation (no climb)
|
|
20
|
+
| { kind: "index"; recv: Expr; index: Expr } // [expr] navigation
|
|
21
|
+
| { kind: "call"; recv: Expr | null; name: string; args: Expr[] } // fn / method
|
|
22
|
+
| { kind: "unary"; op: "!" | "-"; expr: Expr }
|
|
23
|
+
| { kind: "binary"; op: string; left: Expr; right: Expr } // arithmetic + comparison
|
|
24
|
+
| { kind: "logical"; op: "&&" | "||"; left: Expr; right: Expr }
|
|
25
|
+
| { kind: "in"; left: Expr; right: Expr };
|
|
26
|
+
|
|
27
|
+
export interface OrderSpec {
|
|
28
|
+
expr: Expr;
|
|
29
|
+
desc: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One projection item: a named scalar/navigation value, or a named nested
|
|
33
|
+
* collection consumer. `lift` marks a `^name` one-scope lift. */
|
|
34
|
+
export type SelectItem =
|
|
35
|
+
// `lift` is the number of `^` carets: 0 = an ordinary projection, N = a lift
|
|
36
|
+
// that binds this value N scopes out (see the engine's flatten-append).
|
|
37
|
+
| { kind: "field"; name: string; expr: Expr; lift: number }
|
|
38
|
+
| { kind: "collect"; name: string; op: OpNode };
|
|
39
|
+
|
|
40
|
+
/** A postfix consumer directive over a receiver collection:
|
|
41
|
+
* `<receiver> <op> { <sub> }`, optionally `count { … } <relop> <int>`. */
|
|
42
|
+
export interface OpNode {
|
|
43
|
+
kind: "op";
|
|
44
|
+
receiver: Expr;
|
|
45
|
+
op: Consumer;
|
|
46
|
+
sub: Subquery;
|
|
47
|
+
countCmp?: { op: RelOp; value: number };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The recursive `follow` clause. */
|
|
51
|
+
export interface Follow {
|
|
52
|
+
receiver: Expr; // the type-preserving successor relation (a nav expression)
|
|
53
|
+
distinct: boolean;
|
|
54
|
+
where: Expr | null; // successor predicate: which successors keep participating
|
|
55
|
+
frontier: Expr | null; // boundary predicate: cut a relation that could continue
|
|
56
|
+
depth: number | null; // 1..8 cap; null = the hard cap
|
|
57
|
+
by: Expr | null; // identity expression for cycle detection / dedup
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A nested (sub)query body. */
|
|
61
|
+
export interface Subquery {
|
|
62
|
+
from: Expr[]; // body-level `from E` re-projections (flatMap chain)
|
|
63
|
+
where: Where | null;
|
|
64
|
+
select: SelectItem[];
|
|
65
|
+
orderBy: OrderSpec[] | null;
|
|
66
|
+
follow: Follow | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The where-clause boolean tree: OQX owns &&/||/!/grouping so consumer ops
|
|
70
|
+
* (invisible to the scalar evaluator) compose with scalar predicates. */
|
|
71
|
+
export type Where =
|
|
72
|
+
| { kind: "and"; parts: Where[] }
|
|
73
|
+
| { kind: "or"; parts: Where[] }
|
|
74
|
+
| { kind: "not"; expr: Where }
|
|
75
|
+
| { kind: "scalar"; expr: Expr }
|
|
76
|
+
| OpNode;
|
|
77
|
+
|
|
78
|
+
/** A top-level OQX query. */
|
|
79
|
+
export interface Query {
|
|
80
|
+
source: Expr; // the root collection (`from <source>` or the directive receiver)
|
|
81
|
+
from: Expr[]; // further top-level `from E` re-projections
|
|
82
|
+
where: Where | null;
|
|
83
|
+
select: SelectItem[];
|
|
84
|
+
orderBy: OrderSpec[] | null;
|
|
85
|
+
consumer: Consumer;
|
|
86
|
+
follow: Follow | null;
|
|
87
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The tier-2 seam: a DataContext binds OQX's query semantics to a concrete data
|
|
2
|
+
// model. The engine never touches host objects directly — it asks the context to
|
|
3
|
+
// resolve named roots, read properties/relations, coerce a relation result into
|
|
4
|
+
// rows, compute identity (for `follow` dedup), and optionally supply custom
|
|
5
|
+
// scalar functions/methods. This is what lets the same query semantics run over
|
|
6
|
+
// plain objects, a lazy ORM graph, or a remote API without changing the engine.
|
|
7
|
+
//
|
|
8
|
+
// (Performant execution over a real store is the tier-3 seam — see planner.ts —
|
|
9
|
+
// which pushes work into the store instead of driving it row-by-row here.)
|
|
10
|
+
|
|
11
|
+
import { coerceCollection, BUILTIN_FUNCTIONS, BUILTIN_METHODS } from "./semantics.ts";
|
|
12
|
+
|
|
13
|
+
/** Result of a context-provided function/method call: `handled: false` tells the
|
|
14
|
+
* engine to fall back to the builtin table (or error if none). */
|
|
15
|
+
export interface CallResult {
|
|
16
|
+
handled: boolean;
|
|
17
|
+
value?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DataContext {
|
|
21
|
+
/** Resolve a named root collection (the `from <name>` / directive receiver). */
|
|
22
|
+
root(name: string): unknown;
|
|
23
|
+
/** Read a property/relation off a row (`.field`, or a nav-expression segment). */
|
|
24
|
+
get(row: unknown, key: string): unknown;
|
|
25
|
+
/** Whether a row has a property — decides scope-climbing for a bare identifier
|
|
26
|
+
* (a present-but-undefined field stops the climb). Defaults to a key check. */
|
|
27
|
+
has?(row: unknown, key: string): boolean;
|
|
28
|
+
/** Coerce a relation/source value into rows (may be lazy). */
|
|
29
|
+
toRows(value: unknown): Iterable<unknown>;
|
|
30
|
+
/** Identity of a row for `follow` cycle detection / dedup. */
|
|
31
|
+
identity(row: unknown): unknown;
|
|
32
|
+
/** Optional custom free function; return `{ handled: false }` to defer. */
|
|
33
|
+
callFunction?(name: string, args: unknown[]): CallResult;
|
|
34
|
+
/** Optional custom method; return `{ handled: false }` to defer. */
|
|
35
|
+
callMethod?(name: string, recv: unknown, args: unknown[]): CallResult;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The default context: ordinary JavaScript objects. Named roots come from a
|
|
39
|
+
* plain `{ name: collection }` map; properties are own/inherited keys; identity
|
|
40
|
+
* is `.id` when present, else the object itself (reference identity). */
|
|
41
|
+
export class DefaultContext implements DataContext {
|
|
42
|
+
private roots: Record<string, unknown>;
|
|
43
|
+
|
|
44
|
+
constructor(roots: Record<string, unknown> = {}) {
|
|
45
|
+
this.roots = roots;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
root(name: string): unknown {
|
|
49
|
+
return this.roots[name];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get(row: unknown, key: string): unknown {
|
|
53
|
+
if (row == null) return undefined;
|
|
54
|
+
return (Object(row) as Record<string, unknown>)[key];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
has(row: unknown, key: string): boolean {
|
|
58
|
+
return row != null && typeof row === "object" && key in (row as object);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
toRows(value: unknown): Iterable<unknown> {
|
|
62
|
+
return coerceCollection(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
identity(row: unknown): unknown {
|
|
66
|
+
if (row != null && typeof row === "object" && "id" in row) return (row as Record<string, unknown>).id;
|
|
67
|
+
return row;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
callFunction(name: string, args: unknown[]): CallResult {
|
|
71
|
+
const fn = BUILTIN_FUNCTIONS[name];
|
|
72
|
+
return fn ? { handled: true, value: fn(args) } : { handled: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
callMethod(name: string, recv: unknown, args: unknown[]): CallResult {
|
|
76
|
+
const fn = BUILTIN_METHODS[name];
|
|
77
|
+
return fn ? { handled: true, value: fn(recv, args) } : { handled: false };
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// The in-memory execution engine (tier 1), now parameterized by a DataContext
|
|
2
|
+
// (tier 2) so it can drive any data model, and expressed behind an `Engine`
|
|
3
|
+
// interface so a pushdown planner (tier 3, planner.ts) is a drop-in alternative.
|
|
4
|
+
//
|
|
5
|
+
// Optimizations over a naive walk: `exists` short-circuits at the first match;
|
|
6
|
+
// `first`/`single` stop early when the result is unordered; `count` never
|
|
7
|
+
// materializes rows; and within an `&&` the cheap scalar leaves are evaluated
|
|
8
|
+
// before expensive consumer-op leaves (which each drive a nested traversal).
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow,
|
|
12
|
+
} from "./ast.ts";
|
|
13
|
+
import type { DataContext } from "./context.ts";
|
|
14
|
+
import { DefaultContext } from "./context.ts";
|
|
15
|
+
import { OqxError } from "./errors.ts";
|
|
16
|
+
import {
|
|
17
|
+
equals, relate, arith, membership, truthy, toNumber, compareForSort,
|
|
18
|
+
} from "./semantics.ts";
|
|
19
|
+
|
|
20
|
+
/** The shaped result of a top-level query, discriminated by consumer. */
|
|
21
|
+
export type OqxResult =
|
|
22
|
+
| { consumer: "collect"; rows: unknown[] }
|
|
23
|
+
| { consumer: "exists"; exists: boolean }
|
|
24
|
+
| { consumer: "count"; count: number }
|
|
25
|
+
| { consumer: "first"; row: unknown | null }
|
|
26
|
+
| { consumer: "single"; row: unknown | null };
|
|
27
|
+
|
|
28
|
+
/** A backend that runs a parsed Query with the given positional bindings. */
|
|
29
|
+
export interface Engine {
|
|
30
|
+
run(query: Query, bindings: readonly unknown[]): OqxResult;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Scope {
|
|
34
|
+
row: unknown;
|
|
35
|
+
parent: Scope | null;
|
|
36
|
+
bindings: readonly unknown[];
|
|
37
|
+
lifts?: Record<string, unknown>;
|
|
38
|
+
meta?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
42
|
+
const HARD_DEPTH_CAP = 8;
|
|
43
|
+
|
|
44
|
+
export class InMemoryEngine implements Engine {
|
|
45
|
+
private ctx: DataContext;
|
|
46
|
+
|
|
47
|
+
constructor(context: DataContext = new DefaultContext()) {
|
|
48
|
+
this.ctx = context;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
run(query: Query, bindings: readonly unknown[] = []): OqxResult {
|
|
52
|
+
const root: Scope = { row: null, parent: null, bindings };
|
|
53
|
+
let rows = this.rowsOf(this.evalExpr(query.source, root));
|
|
54
|
+
for (const proj of query.from) {
|
|
55
|
+
rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (query.follow) return this.runFollow(query, rows, root);
|
|
59
|
+
|
|
60
|
+
// Consumer-directed short-circuits.
|
|
61
|
+
if (query.consumer === "exists") {
|
|
62
|
+
for (const r of rows) if (this.matches(query.where, r, root)) return { consumer: "exists", exists: true };
|
|
63
|
+
return { consumer: "exists", exists: false };
|
|
64
|
+
}
|
|
65
|
+
if (query.consumer === "count") {
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (const r of rows) if (this.matches(query.where, r, root)) count++;
|
|
68
|
+
return { consumer: "count", count };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const cap = !query.orderBy && query.consumer === "first" ? 1
|
|
72
|
+
: !query.orderBy && query.consumer === "single" ? 2
|
|
73
|
+
: Infinity;
|
|
74
|
+
const kept: Scope[] = [];
|
|
75
|
+
for (const r of rows) {
|
|
76
|
+
const s: Scope = { row: r, parent: root, bindings, lifts: {} };
|
|
77
|
+
if (!query.where || this.evalWhere(query.where, s)) {
|
|
78
|
+
kept.push(s);
|
|
79
|
+
if (kept.length >= cap) break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.sortScopes(kept, query.orderBy);
|
|
83
|
+
return this.shape(query.consumer, kept, query.select);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A where match that needs no lift capture (exists/count fast paths).
|
|
87
|
+
private matches(where: Where | null, row: unknown, parent: Scope): boolean {
|
|
88
|
+
if (!where) return true;
|
|
89
|
+
return this.evalWhere(where, { row, parent, bindings: parent.bindings });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private rowsOf(v: unknown): unknown[] {
|
|
93
|
+
return Array.from(this.ctx.toRows(v));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private child(row: unknown, parent: Scope): Scope {
|
|
97
|
+
return { row, parent, bindings: parent.bindings };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- follow ---------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
private runFollow(query: Query, rows: unknown[], root: Scope): OqxResult {
|
|
103
|
+
const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
|
|
104
|
+
const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
|
|
105
|
+
const occ = this.followWalk(seeds, query.follow!, root);
|
|
106
|
+
let scopes: Scope[] = occ.map((o) => ({ row: o.row, parent: root, bindings: root.bindings, lifts: {}, meta: o.meta }));
|
|
107
|
+
if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
108
|
+
this.sortScopes(scopes, query.orderBy);
|
|
109
|
+
return this.shape(query.consumer, scopes, query.select);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
|
|
113
|
+
const cap = follow.depth ?? HARD_DEPTH_CAP;
|
|
114
|
+
const idOf = (row: unknown): unknown =>
|
|
115
|
+
follow.by ? this.evalExpr(follow.by, { row, parent, bindings: parent.bindings }) : this.ctx.identity(row);
|
|
116
|
+
const occ: Occurrence[] = [];
|
|
117
|
+
const seen = new Set<unknown>();
|
|
118
|
+
let level: { row: unknown; depth: number }[] = seedRows.map((r) => ({ row: r, depth: 1 }));
|
|
119
|
+
|
|
120
|
+
while (level.length > 0) {
|
|
121
|
+
const next: { row: unknown; depth: number }[] = [];
|
|
122
|
+
for (const cur of level) {
|
|
123
|
+
const key = idOf(cur.row);
|
|
124
|
+
if (seen.has(key)) continue;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
const s: Scope = { row: cur.row, parent, bindings: parent.bindings };
|
|
127
|
+
const raw = this.rowsOf(this.evalExpr(follow.receiver, s));
|
|
128
|
+
const succ = follow.where
|
|
129
|
+
? raw.filter((x) => truthy(this.evalExpr(follow.where!, { row: x, parent, bindings: parent.bindings })))
|
|
130
|
+
: raw;
|
|
131
|
+
const atCap = cur.depth >= cap;
|
|
132
|
+
const frontierHit = follow.frontier ? truthy(this.evalExpr(follow.frontier, s)) : false;
|
|
133
|
+
const isLeaf = succ.length === 0;
|
|
134
|
+
const stop = frontierHit ? "frontier" : atCap ? "depth" : isLeaf ? "leaf" : "continue";
|
|
135
|
+
occ.push({ row: cur.row, meta: { $depth: cur.depth, $stop: stop, $leaf: isLeaf, $frontier: frontierHit } });
|
|
136
|
+
if (!atCap && !frontierHit && !isLeaf) for (const x of succ) next.push({ row: x, depth: cur.depth + 1 });
|
|
137
|
+
}
|
|
138
|
+
level = next;
|
|
139
|
+
}
|
|
140
|
+
return occ;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- consumer shaping -----------------------------------------------------
|
|
144
|
+
|
|
145
|
+
private shape(consumer: Query["consumer"], scopes: Scope[], select: SelectItem[]): OqxResult {
|
|
146
|
+
switch (consumer) {
|
|
147
|
+
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
148
|
+
case "count": return { consumer, count: scopes.length };
|
|
149
|
+
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(select, s)) };
|
|
150
|
+
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(select, scopes[0]!) : null };
|
|
151
|
+
case "single":
|
|
152
|
+
if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
|
|
153
|
+
return { consumer, row: scopes.length > 0 ? this.projectRow(select, scopes[0]!) : null };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private projectRow(select: SelectItem[], scope: Scope): unknown {
|
|
158
|
+
if (select.length === 0) return scope.row;
|
|
159
|
+
const out: Record<string, unknown> = {};
|
|
160
|
+
for (const item of select) {
|
|
161
|
+
out[item.name] = item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- where evaluation -----------------------------------------------------
|
|
167
|
+
|
|
168
|
+
private evalWhere(w: Where, scope: Scope): boolean {
|
|
169
|
+
switch (w.kind) {
|
|
170
|
+
case "and": {
|
|
171
|
+
// Cheap scalar leaves before expensive consumer ops; `.every` short-circuits.
|
|
172
|
+
for (const p of orderByCost(w.parts)) if (!this.evalWhere(p, scope)) return false;
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
case "or": return w.parts.some((p) => this.evalWhere(p, scope));
|
|
176
|
+
case "not": return !this.evalWhere(w.expr, scope);
|
|
177
|
+
case "scalar": return truthy(this.evalExpr(w.expr, scope));
|
|
178
|
+
case "op": return this.evalWhereOp(w, scope);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private evalWhereOp(op: OpNode, scope: Scope): boolean {
|
|
183
|
+
if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
|
|
184
|
+
if (op.op === "exists") return this.anyMatch(op, scope);
|
|
185
|
+
if (op.op === "collect") {
|
|
186
|
+
const matched = this.matchRows(op, scope);
|
|
187
|
+
for (const item of op.sub.select) {
|
|
188
|
+
if (item.kind !== "field") continue;
|
|
189
|
+
// Bind `item.lift` scopes out: `^` = the collect's own scope, `^^` its
|
|
190
|
+
// parent, etc. Values flatten-append into the target scope, so repeated
|
|
191
|
+
// evaluations (a deeper lift fanning out through intermediate scopes)
|
|
192
|
+
// accumulate into one flat list rather than overwriting.
|
|
193
|
+
let target: Scope = scope;
|
|
194
|
+
for (let i = 1; i < item.lift && target.parent; i++) target = target.parent;
|
|
195
|
+
const lifts = (target.lifts ??= {});
|
|
196
|
+
const prior = Array.isArray(lifts[item.name]) ? (lifts[item.name] as unknown[]) : [];
|
|
197
|
+
lifts[item.name] = prior.concat(matched.map((s) => this.evalExpr(item.expr, s)));
|
|
198
|
+
}
|
|
199
|
+
return matched.length > 0;
|
|
200
|
+
}
|
|
201
|
+
if (op.op === "count") {
|
|
202
|
+
const n = this.matchRows(op, scope).length;
|
|
203
|
+
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
204
|
+
}
|
|
205
|
+
return this.matchRows(op, scope).length > 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Short-circuiting existence check over a consumer receiver.
|
|
209
|
+
private anyMatch(op: OpNode, scope: Scope): boolean {
|
|
210
|
+
for (const s of this.iterMatchRows(op, scope)) { void s; return true; }
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private *iterMatchRows(op: OpNode, scope: Scope): Generator<Scope> {
|
|
215
|
+
let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
|
|
216
|
+
for (const proj of op.sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
|
|
217
|
+
for (const r of rows) {
|
|
218
|
+
const s: Scope = { row: r, parent: scope, bindings: scope.bindings, lifts: {} };
|
|
219
|
+
if (!op.sub.where || this.evalWhere(op.sub.where, s)) yield s;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private matchRows(op: OpNode, scope: Scope): Scope[] {
|
|
224
|
+
return Array.from(this.iterMatchRows(op, scope));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A select-position collect/first/single, optionally recursive via `follow`.
|
|
228
|
+
private evalCollectValue(op: OpNode, scope: Scope): unknown {
|
|
229
|
+
const sub = op.sub;
|
|
230
|
+
let scopes: Scope[];
|
|
231
|
+
if (sub.follow) {
|
|
232
|
+
let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
|
|
233
|
+
for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
|
|
234
|
+
const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, { row: r, parent: scope, bindings: scope.bindings, lifts: {} })) : rows;
|
|
235
|
+
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => ({ row: o.row, parent: scope, bindings: scope.bindings, meta: o.meta }));
|
|
236
|
+
} else {
|
|
237
|
+
scopes = this.matchRows(op, scope);
|
|
238
|
+
}
|
|
239
|
+
this.sortScopes(scopes, sub.orderBy);
|
|
240
|
+
switch (op.op) {
|
|
241
|
+
case "collect": return scopes.map((s) => this.projectRow(sub.select, s));
|
|
242
|
+
case "first": return scopes.length > 0 ? this.projectRow(sub.select, scopes[0]!) : null;
|
|
243
|
+
case "single":
|
|
244
|
+
if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
|
|
245
|
+
return scopes.length > 0 ? this.projectRow(sub.select, scopes[0]!) : null;
|
|
246
|
+
default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- ordering -------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
private sortScopes(scopes: Scope[], orderBy: OrderSpec[] | null): void {
|
|
253
|
+
if (!orderBy || orderBy.length === 0) return;
|
|
254
|
+
scopes.sort((a, b) => {
|
|
255
|
+
for (const spec of orderBy) {
|
|
256
|
+
const c = compareForSort(this.evalExpr(spec.expr, a), this.evalExpr(spec.expr, b));
|
|
257
|
+
if (c !== 0) return spec.desc ? -c : c;
|
|
258
|
+
}
|
|
259
|
+
return 0;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- scalar expression evaluation -----------------------------------------
|
|
264
|
+
|
|
265
|
+
private evalExpr(e: Expr, scope: Scope): unknown {
|
|
266
|
+
switch (e.kind) {
|
|
267
|
+
case "lit": return e.value;
|
|
268
|
+
case "binding": return scope.bindings[e.index];
|
|
269
|
+
case "ident": return this.resolveFrom(e.name, scope);
|
|
270
|
+
case "outer": {
|
|
271
|
+
let s: Scope | null = scope;
|
|
272
|
+
for (let i = 0; i < e.levels && s; i++) s = s.parent;
|
|
273
|
+
return s ? this.resolveFrom(e.name, s) : undefined;
|
|
274
|
+
}
|
|
275
|
+
case "member": {
|
|
276
|
+
const r = this.evalExpr(e.recv, scope);
|
|
277
|
+
return r == null ? undefined : this.ctx.get(r, e.name);
|
|
278
|
+
}
|
|
279
|
+
case "index": {
|
|
280
|
+
const r = this.evalExpr(e.recv, scope);
|
|
281
|
+
const i = this.evalExpr(e.index, scope);
|
|
282
|
+
return r == null ? undefined : this.ctx.get(r, String(i));
|
|
283
|
+
}
|
|
284
|
+
case "call": return this.evalCall(e, scope);
|
|
285
|
+
case "unary":
|
|
286
|
+
return e.op === "!" ? !truthy(this.evalExpr(e.expr, scope)) : -toNumber(this.evalExpr(e.expr, scope));
|
|
287
|
+
case "binary": {
|
|
288
|
+
const l = this.evalExpr(e.left, scope);
|
|
289
|
+
const r = this.evalExpr(e.right, scope);
|
|
290
|
+
return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
|
|
291
|
+
}
|
|
292
|
+
case "logical": {
|
|
293
|
+
const l = this.evalExpr(e.left, scope);
|
|
294
|
+
if (e.op === "&&") return truthy(l) ? this.evalExpr(e.right, scope) : l;
|
|
295
|
+
return truthy(l) ? l : this.evalExpr(e.right, scope);
|
|
296
|
+
}
|
|
297
|
+
case "in": return membership(this.evalExpr(e.left, scope), this.evalExpr(e.right, scope));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Resolve a bare name starting at `start` and climbing enclosing scopes: `^`
|
|
302
|
+
// outer references reuse this after skipping the requested number of scopes.
|
|
303
|
+
private resolveFrom(name: string, start: Scope): unknown {
|
|
304
|
+
if (RECUR.has(name)) {
|
|
305
|
+
for (let s: Scope | null = start; s; s = s.parent) if (s.meta && name in s.meta) return s.meta[name];
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
for (let s: Scope | null = start; s; s = s.parent) {
|
|
309
|
+
if (s.lifts && name in s.lifts) return s.lifts[name];
|
|
310
|
+
if (s.parent === null) {
|
|
311
|
+
const v = this.ctx.root(name);
|
|
312
|
+
if (v !== undefined) return v;
|
|
313
|
+
} else if (this.has(s.row, name)) {
|
|
314
|
+
return this.ctx.get(s.row, name);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private has(row: unknown, name: string): boolean {
|
|
321
|
+
if (this.ctx.has) return this.ctx.has(row, name);
|
|
322
|
+
return row != null && typeof row === "object" && name in (row as object);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private evalCall(e: Extract<Expr, { kind: "call" }>, scope: Scope): unknown {
|
|
326
|
+
const args = e.args.map((a) => this.evalExpr(a, scope));
|
|
327
|
+
if (e.recv === null) {
|
|
328
|
+
const r = this.ctx.callFunction?.(e.name, args);
|
|
329
|
+
if (r?.handled) return r.value;
|
|
330
|
+
throw new OqxError(`unknown function '${e.name}(…)'`, "eval");
|
|
331
|
+
}
|
|
332
|
+
const recv = this.evalExpr(e.recv, scope);
|
|
333
|
+
const r = this.ctx.callMethod?.(e.name, recv, args);
|
|
334
|
+
if (r?.handled) return r.value;
|
|
335
|
+
throw new OqxError(`unknown method '.${e.name}(…)'`, "eval");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface Occurrence { row: unknown; meta: Record<string, unknown>; }
|
|
340
|
+
|
|
341
|
+
// ---- free helpers -----------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
function isRelOp(op: string): boolean {
|
|
344
|
+
return op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function compareCount(n: number, cmp: { op: string; value: number }): boolean {
|
|
348
|
+
return relate(cmp.op, n, cmp.value);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Order `&&` conjuncts so cheap scalar leaves run before consumer-op leaves.
|
|
352
|
+
function orderByCost(parts: Where[]): Where[] {
|
|
353
|
+
return [...parts].sort((a, b) => whereCost(a) - whereCost(b));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function whereCost(w: Where): number {
|
|
357
|
+
switch (w.kind) {
|
|
358
|
+
case "op": return 2;
|
|
359
|
+
case "and": case "or": return Math.max(0, ...w.parts.map(whereCost));
|
|
360
|
+
case "not": return whereCost(w.expr);
|
|
361
|
+
case "scalar": return 0;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function describeReceiver(e: Expr): string {
|
|
366
|
+
if (e.kind === "ident") return e.name;
|
|
367
|
+
if (e.kind === "member") return `${describeReceiver(e.recv)}.${e.name}`;
|
|
368
|
+
if (e.kind === "binding") return `\${${e.index}}`;
|
|
369
|
+
return "receiver";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function partitionRecur(w: Where): { seed: Where | null; post: Where | null } {
|
|
373
|
+
const parts = w.kind === "and" ? w.parts : [w];
|
|
374
|
+
const seed: Where[] = [];
|
|
375
|
+
const post: Where[] = [];
|
|
376
|
+
for (const p of parts) (whereHasRecur(p) ? post : seed).push(p);
|
|
377
|
+
const rebuild = (ps: Where[]): Where | null => (ps.length === 0 ? null : ps.length === 1 ? ps[0]! : { kind: "and", parts: ps });
|
|
378
|
+
return { seed: rebuild(seed), post: rebuild(post) };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function whereHasRecur(w: Where): boolean {
|
|
382
|
+
switch (w.kind) {
|
|
383
|
+
case "and": case "or": return w.parts.some(whereHasRecur);
|
|
384
|
+
case "not": return whereHasRecur(w.expr);
|
|
385
|
+
case "scalar": return exprHasRecur(w.expr);
|
|
386
|
+
case "op": return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function exprHasRecur(e: Expr): boolean {
|
|
391
|
+
switch (e.kind) {
|
|
392
|
+
case "ident": return RECUR.has(e.name);
|
|
393
|
+
case "member": return exprHasRecur(e.recv);
|
|
394
|
+
case "index": return exprHasRecur(e.recv) || exprHasRecur(e.index);
|
|
395
|
+
case "call": return (e.recv ? exprHasRecur(e.recv) : false) || e.args.some(exprHasRecur);
|
|
396
|
+
case "unary": return exprHasRecur(e.expr);
|
|
397
|
+
case "binary": case "logical": case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
|
|
398
|
+
default: return false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Convenience: run a query with plain-object roots (the default context). */
|
|
403
|
+
export function runQuery(query: Query, bindings: readonly unknown[], roots: unknown): OqxResult {
|
|
404
|
+
const ctx = new DefaultContext((roots as Record<string, unknown>) ?? {});
|
|
405
|
+
return new InMemoryEngine(ctx).run(query, bindings);
|
|
406
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A single error type for every OQX failure — lex, parse, and evaluation. `stage`
|
|
2
|
+
// distinguishes where it came from so callers (and tests) can branch without
|
|
3
|
+
// string-matching messages. Mirrors the reference impl's FilterInvalid, but the
|
|
4
|
+
// generic kernel has no SQL/CEL layer to name.
|
|
5
|
+
|
|
6
|
+
export type OqxStage = "lex" | "parse" | "eval";
|
|
7
|
+
|
|
8
|
+
export class OqxError extends Error {
|
|
9
|
+
readonly stage: OqxStage;
|
|
10
|
+
constructor(message: string, stage: OqxStage) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "OqxError";
|
|
13
|
+
this.stage = stage;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// OQX for JavaScript — a generic object-query language.
|
|
2
|
+
//
|
|
3
|
+
// The primary API is a tagged template:
|
|
4
|
+
//
|
|
5
|
+
// const employees = oqx`
|
|
6
|
+
// name, id, title
|
|
7
|
+
// from ${people}
|
|
8
|
+
// where jobs exists { employer == ${company} && !end_date }
|
|
9
|
+
// `;
|
|
10
|
+
//
|
|
11
|
+
// Interpolations cross the host/OQX boundary as typed VALUE bindings, never as
|
|
12
|
+
// source text (prepared-statement semantics).
|
|
13
|
+
//
|
|
14
|
+
// The engine is layered so OQX can be a foundation for other query systems:
|
|
15
|
+
// • tier 1 — `InMemoryEngine` over a `DataContext` (this file's default);
|
|
16
|
+
// • tier 2 — a custom `DataContext` binds any data model (ORM, remote, …);
|
|
17
|
+
// • tier 3 — a `QueryPlanner` pushes work into a store (see `oqx/sqlite`),
|
|
18
|
+
// with `PlannedEngine` finishing the residual in-memory.
|
|
19
|
+
// All backends must obey the scalar rules in `./semantics.ts` (the conformance
|
|
20
|
+
// suite verifies this).
|
|
21
|
+
|
|
22
|
+
import type { Query } from "./ast.ts";
|
|
23
|
+
import { parseTemplate, parseString } from "./parser.ts";
|
|
24
|
+
import type { Engine, OqxResult } from "./engine.ts";
|
|
25
|
+
import { InMemoryEngine, runQuery } from "./engine.ts";
|
|
26
|
+
import type { DataContext } from "./context.ts";
|
|
27
|
+
import { DefaultContext } from "./context.ts";
|
|
28
|
+
|
|
29
|
+
export { OqxError } from "./errors.ts";
|
|
30
|
+
export type { OqxResult, Engine } from "./engine.ts";
|
|
31
|
+
export { InMemoryEngine, runQuery } from "./engine.ts";
|
|
32
|
+
export type { DataContext, CallResult } from "./context.ts";
|
|
33
|
+
export { DefaultContext } from "./context.ts";
|
|
34
|
+
export type { QueryPlanner, Plan } from "./planner.ts";
|
|
35
|
+
export { PlannedEngine } from "./planner.ts";
|
|
36
|
+
export { IndexedCollection } from "./adapters/indexed.ts";
|
|
37
|
+
export { ROWS_ROOT, partitionPushable, residualQuery, asEquality, isConst, constValue } from "./plan.ts";
|
|
38
|
+
export * as semantics from "./semantics.ts";
|
|
39
|
+
export type * from "./ast.ts";
|
|
40
|
+
|
|
41
|
+
// Compiled-query cache keyed by the template's stable `strings` identity, so the
|
|
42
|
+
// same call site parses once and re-runs with fresh bindings.
|
|
43
|
+
const templateCache = new WeakMap<TemplateStringsArray, Query>();
|
|
44
|
+
|
|
45
|
+
/** The OQX tagged template. Returns the query result shaped by its consumer:
|
|
46
|
+
* an array for `collect` (the default), a boolean for `exists`, a number for
|
|
47
|
+
* `count`, or a single record / null for `first` / `single`. */
|
|
48
|
+
export function oqx(strings: TemplateStringsArray, ...values: unknown[]): unknown {
|
|
49
|
+
let query = templateCache.get(strings);
|
|
50
|
+
if (!query) {
|
|
51
|
+
query = parseTemplate(strings, values.length);
|
|
52
|
+
templateCache.set(strings, query);
|
|
53
|
+
}
|
|
54
|
+
return unwrap(runQuery(query, values, undefined));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse a query string into a reusable AST. */
|
|
58
|
+
export function parse(source: string): Query {
|
|
59
|
+
return parseString(source);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Run a string query against a data context of named roots (e.g. `{ people }`,
|
|
63
|
+
* so `from people` resolves), returning the consumer-shaped result. */
|
|
64
|
+
export function execute(source: string, roots?: Record<string, unknown>): unknown {
|
|
65
|
+
return unwrap(runQuery(parseString(source), [], roots));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run a pre-parsed query with explicit bindings and/or a backend, returning the
|
|
69
|
+
* full discriminated result. Provide `engine` (any `Engine`), or `context` (a
|
|
70
|
+
* `DataContext`, run in-memory), or `roots` (plain-object named roots). */
|
|
71
|
+
export function run(
|
|
72
|
+
query: Query,
|
|
73
|
+
opts: { values?: readonly unknown[]; roots?: Record<string, unknown>; context?: DataContext; engine?: Engine } = {},
|
|
74
|
+
): OqxResult {
|
|
75
|
+
const values = opts.values ?? [];
|
|
76
|
+
if (opts.engine) return opts.engine.run(query, values);
|
|
77
|
+
const context = opts.context ?? new DefaultContext(opts.roots ?? {});
|
|
78
|
+
return new InMemoryEngine(context).run(query, values);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function unwrap(result: OqxResult): unknown {
|
|
82
|
+
switch (result.consumer) {
|
|
83
|
+
case "collect": return result.rows;
|
|
84
|
+
case "exists": return result.exists;
|
|
85
|
+
case "count": return result.count;
|
|
86
|
+
case "first": case "single": return result.row;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default oqx;
|