@omgbase/oqx 0.14.1 → 0.18.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 (79) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +192 -34
  3. package/dist/ast.d.ts +77 -23
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +21 -6
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +37 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +54 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/context.d.ts +13 -3
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +7 -3
  14. package/dist/context.js.map +1 -1
  15. package/dist/engine.d.ts +1 -2
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engine.js +55 -26
  18. package/dist/engine.js.map +1 -1
  19. package/dist/errors.d.ts +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +2 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +19 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +34 -12
  26. package/dist/index.js.map +1 -1
  27. package/dist/lexer.d.ts +15 -1
  28. package/dist/lexer.d.ts.map +1 -1
  29. package/dist/lexer.js +104 -51
  30. package/dist/lexer.js.map +1 -1
  31. package/dist/optimize/analysis.d.ts +1 -1
  32. package/dist/optimize/analysis.d.ts.map +1 -1
  33. package/dist/optimize/analysis.js +7 -5
  34. package/dist/optimize/analysis.js.map +1 -1
  35. package/dist/optimize/ir.d.ts +3 -3
  36. package/dist/optimize/ir.d.ts.map +1 -1
  37. package/dist/optimize/ir.js +3 -3
  38. package/dist/optimize/ir.js.map +1 -1
  39. package/dist/parser.d.ts.map +1 -1
  40. package/dist/parser.js +616 -251
  41. package/dist/parser.js.map +1 -1
  42. package/dist/plan.d.ts.map +1 -1
  43. package/dist/plan.js +5 -3
  44. package/dist/plan.js.map +1 -1
  45. package/dist/print.d.ts +17 -0
  46. package/dist/print.d.ts.map +1 -0
  47. package/dist/print.js +266 -0
  48. package/dist/print.js.map +1 -0
  49. package/dist/resolve.d.ts +23 -0
  50. package/dist/resolve.d.ts.map +1 -0
  51. package/dist/resolve.js +330 -0
  52. package/dist/resolve.js.map +1 -0
  53. package/dist/semantics.js +1 -1
  54. package/dist/semantics.js.map +1 -1
  55. package/dist/version.d.ts +5 -0
  56. package/dist/version.d.ts.map +1 -0
  57. package/dist/version.js +5 -0
  58. package/dist/version.js.map +1 -0
  59. package/dist/walk.d.ts +54 -0
  60. package/dist/walk.d.ts.map +1 -0
  61. package/dist/walk.js +139 -0
  62. package/dist/walk.js.map +1 -0
  63. package/package.json +1 -1
  64. package/src/ast.ts +105 -36
  65. package/src/build.ts +72 -0
  66. package/src/context.ts +16 -3
  67. package/src/engine.ts +57 -28
  68. package/src/errors.ts +3 -2
  69. package/src/index.ts +38 -13
  70. package/src/lexer.ts +102 -51
  71. package/src/optimize/analysis.ts +6 -5
  72. package/src/optimize/ir.ts +5 -5
  73. package/src/parser.ts +573 -225
  74. package/src/plan.ts +4 -3
  75. package/src/print.ts +274 -0
  76. package/src/resolve.ts +305 -0
  77. package/src/semantics.ts +1 -1
  78. package/src/version.ts +4 -0
  79. package/src/walk.ts +180 -0
package/src/plan.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // climbs to an enclosing scope or a named root), an `ident` in a top-level
14
14
  // `where` is unambiguously a column of the scanned rows and is safe to push. An
15
15
  // `outer` (`^name`) reference is not a row column and stays residual, and so is
16
- // the `$value` intrinsic (the row itself, not one of its columns) — adapters
16
+ // the `$it` intrinsic (the row itself, not one of its columns) — adapters
17
17
  // gate idents on their known column set, which never includes it.
18
18
 
19
19
  import type { Query, Where, Expr } from "./ast.ts";
@@ -34,14 +34,15 @@ export function partitionPushable(
34
34
  if (p.kind === "scalar" && canPush(p.expr)) pushed.push(p.expr);
35
35
  else rest.push(p);
36
36
  }
37
- const residual: Where | null = rest.length === 0 ? null : rest.length === 1 ? rest[0]! : { kind: "and", parts: rest };
37
+ if (pushed.length === 0) return { pushed, residual: where }; // nothing pushed: the where itself, span and all
38
+ const residual: Where | null = rest.length === 0 ? null : rest.length === 1 ? rest[0]! : { kind: "and", span: [0, 0], parts: rest };
38
39
  return { pushed, residual };
39
40
  }
40
41
 
41
42
  /** Rebuild a query to run in-memory over a plan's produced rows: scan the rows
42
43
  * root, drop pushed top-level `from`/predicates, keep projection/order/consumer. */
43
44
  export function residualQuery(query: Query, residualWhere: Where | null): Query {
44
- return { ...query, source: { kind: "ident", name: ROWS_ROOT }, from: [], where: residualWhere };
45
+ return { ...query, source: { kind: "ident", span: [0, 0], name: ROWS_ROOT }, from: [], where: residualWhere };
45
46
  }
46
47
 
47
48
  /** A literal or binding — a value known without a row context. */
package/src/print.ts ADDED
@@ -0,0 +1,274 @@
1
+ // The canonical printer (spec/oqx/AST.md §6): the one source text of a tree.
2
+ // Single spaces, double-quoted strings with GRAMMAR §1 escapes, the minimal
3
+ // parentheses the precedence table (GRAMMAR §4) needs, `select` written at the
4
+ // top level, the connectives as the words `and` / `or` (since 0.18; `!` stays
5
+ // `!`), every option in its canonical position. The law both spec runners
6
+ // enforce over every fixture query:
7
+ //
8
+ // strip(parse(print(parse(q)))) ≡ strip(parse(q))
9
+ //
10
+ // `print(parse(q)) == q` is NOT a law — the printer normalizes spelling. A
11
+ // binding is a value, never source text: `print` throws on one; `printTemplate`
12
+ // emits template fragments around each binding instead.
13
+
14
+ import type { AstNode, Expr, Follow, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
15
+ import { OqxError } from "./errors.ts";
16
+
17
+ /** The template form of a tree: `strings` has `count + 1` fragments; the i-th gap
18
+ * stands for the binding `indices[i]` of the printed tree, so a caller re-runs it
19
+ * as `parseTemplate(strings, count)` with the values permuted by `indices`. In a
20
+ * tree straight from `parseTemplate`, `indices` is `0..count-1` unless the
21
+ * canonical clause order moved a binding past another (`${0} collect { x: ${1} }`
22
+ * prints as `select x: ${1} from ${0}`). */
23
+ export interface Template {
24
+ strings: string[];
25
+ count: number;
26
+ indices: number[];
27
+ }
28
+
29
+ /** The canonical source of a node. Throws (`OqxError`, stage `print`) on a binding. */
30
+ export function print(node: AstNode): string {
31
+ let text = "";
32
+ for (const p of render(node)) {
33
+ if (typeof p === "number") {
34
+ throw new OqxError(`cannot print binding \${${p}}: a binding is a value, not source text — use printTemplate`, "print");
35
+ }
36
+ text += p;
37
+ }
38
+ return text;
39
+ }
40
+
41
+ /** The canonical source of a node as template fragments, one gap per binding. */
42
+ export function printTemplate(node: AstNode): Template {
43
+ const strings: string[] = [];
44
+ const indices: number[] = [];
45
+ let current = "";
46
+ for (const p of render(node)) {
47
+ if (typeof p === "number") { strings.push(current); current = ""; indices.push(p); }
48
+ else current += p;
49
+ }
50
+ strings.push(current);
51
+ return { strings, count: indices.length, indices };
52
+ }
53
+
54
+ // Output is a list of pieces: source text, or a binding index standing where the
55
+ // binding goes (so a template can be split exactly there).
56
+ type Out = (string | number)[];
57
+
58
+ const cat = (...xs: (string | Out)[]): Out => xs.flatMap((x) => (typeof x === "string" ? [x] : x));
59
+ const join = (parts: Out[], sep: string): Out => parts.flatMap((p, i) => (i === 0 ? p : [sep, ...p]));
60
+ const paren = (o: Out, yes: boolean): Out => (yes ? cat("(", o, ")") : o);
61
+
62
+ // Precedence levels of GRAMMAR §4, loosest to tightest. A child is parenthesized
63
+ // when its level is below what its position requires.
64
+ const OR = 1, AND = 2, CMP = 3, RANGE = 4, ADD = 5, MUL = 6, UNARY = 7, POSTFIX = 8, PRIMARY = 9;
65
+
66
+ function prec(e: Expr): number {
67
+ switch (e.kind) {
68
+ case "lit": case "ident": case "outer": case "binding": return PRIMARY;
69
+ case "member": case "call": case "required": case "op": return POSTFIX;
70
+ case "unary": return UNARY;
71
+ case "binary": return e.op === "+" || e.op === "-" ? ADD : e.op === "*" || e.op === "/" || e.op === "%" ? MUL : CMP;
72
+ case "in": return CMP;
73
+ case "range": return RANGE;
74
+ case "logical": return e.op === "&&" ? AND : OR;
75
+ }
76
+ }
77
+
78
+ function render(n: AstNode): Out {
79
+ switch (n.kind) {
80
+ case "query": return query(n);
81
+ case "subquery": return block(n);
82
+ case "op": return op(n);
83
+ case "follow": return follow(n);
84
+ case "order": return order(n);
85
+ case "field": case "collect": return item(n);
86
+ case "and": case "or": case "not": case "scalar": return where(n);
87
+ default: return expr(n);
88
+ }
89
+ }
90
+
91
+ // ---- query / bodies ---------------------------------------------------------
92
+
93
+ function query(q: Query): Out {
94
+ // Body form for `collect` — unless the query carries body-level `from`
95
+ // re-projections, which only a block can hold (a second top-level `from` is a
96
+ // duplicate clause): then the directive form `source collect { … from E }`.
97
+ if (q.consumer === "collect" && q.from.length === 0) {
98
+ // `select` is always written — except for a leading bare item literally
99
+ // named `distinct`, which the keyword would swallow as the modifier.
100
+ const first = q.select[0];
101
+ const bareDistinct = !q.distinct && first?.kind === "field" && first.name === "distinct"
102
+ && first.expr.kind === "ident" && first.expr.name === "distinct";
103
+ const clauses: Out[] = [];
104
+ if (q.select.length > 0) {
105
+ const items = projection(q.select, q.values);
106
+ clauses.push(bareDistinct ? items : cat(`select ${q.distinct ? "distinct " : ""}`, items));
107
+ }
108
+ clauses.push(cat("from ", expr(q.source)));
109
+ clauses.push(...tail(q));
110
+ return join(clauses, " ");
111
+ }
112
+ // Directive form: `<source> <consumer> [distinct] { body }`.
113
+ return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
114
+ }
115
+
116
+ type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "orderBy" | "limit" | "offset">;
117
+
118
+ // The clauses after `from` (a query) or after the projection (a block): the
119
+ // fixed clause order, each present clause once.
120
+ function tail(b: Omit<Body, "select" | "values">): Out[] {
121
+ const out: Out[] = [];
122
+ for (const e of b.from) out.push(cat("from ", expr(e)));
123
+ if (b.where) out.push(cat("where ", where(b.where)));
124
+ if (b.follow) out.push(follow(b.follow));
125
+ if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
126
+ if (b.limit) out.push(cat("limit ", expr(b.limit)));
127
+ if (b.offset) out.push(cat("offset ", expr(b.offset)));
128
+ return out;
129
+ }
130
+
131
+ // A block body: the projection leads without its keyword (the body's first
132
+ // clause is always the projection, so the keyword adds nothing there).
133
+ function body(b: Body): Out[] {
134
+ const clauses: Out[] = [];
135
+ if (b.select.length > 0) clauses.push(projection(b.select, b.values));
136
+ clauses.push(...tail(b));
137
+ return clauses;
138
+ }
139
+
140
+ function block(sub: Subquery): Out {
141
+ return braces(body(sub));
142
+ }
143
+
144
+ function braces(clauses: Out[]): Out {
145
+ return clauses.length === 0 ? ["{ }"] : cat("{ ", join(clauses, " "), " }");
146
+ }
147
+
148
+ function projection(items: SelectItem[], values: boolean): Out {
149
+ return cat(join(items.map(item), ", "), values ? " values" : "");
150
+ }
151
+
152
+ function item(it: SelectItem): Out {
153
+ // An unnamed directive item exists only under `values` (`jobs[0] values`).
154
+ if (it.kind === "collect") return it.name === "" ? op(it.op) : cat(`${it.name}: `, op(it.op));
155
+ return cat("^".repeat(it.lift), it.name === "" ? "" : `${it.name}: `, expr(it.expr));
156
+ }
157
+
158
+ function order(o: OrderSpec): Out {
159
+ return cat(expr(o.expr), o.desc ? " desc" : "");
160
+ }
161
+
162
+ function op(o: OpNode): Out {
163
+ return cat(
164
+ receiver(o.receiver),
165
+ ` ${o.op}${o.distinct ? " distinct" : ""} `,
166
+ block(o.sub),
167
+ o.countCmp ? ` ${o.countCmp.op} ${number(o.countCmp.value)}` : "",
168
+ );
169
+ }
170
+
171
+ function follow(f: Follow): Out {
172
+ const dests = join(f.destinations.map((d) => (d.kind === "op" ? op(d) : expr(d))), ", ");
173
+ const opts: Out[] = [];
174
+ if (f.where) opts.push(cat("where ", expr(f.where)));
175
+ if (f.frontier) opts.push(cat("frontier ", expr(f.frontier)));
176
+ if (f.depth !== null) opts.push([`depth ${f.depth}`]);
177
+ if (f.by) opts.push(cat("by ", expr(f.by)));
178
+ return cat(`follow${f.distinct ? " distinct" : ""} `, dests, opts.length > 0 ? cat(" { ", join(opts, " "), " }") : "");
179
+ }
180
+
181
+ // ---- where ------------------------------------------------------------------
182
+
183
+ function where(w: Where): Out {
184
+ switch (w.kind) {
185
+ // The connectives print as the words (`and` / `or`, since 0.18); the symbols
186
+ // parse to the same nodes.
187
+ case "and": return join(w.parts.map((p) => paren(where(p), p.kind === "or" || p.kind === "and")), " and ");
188
+ case "or": return join(w.parts.map((p) => paren(where(p), p.kind === "or")), " or ");
189
+ case "not": {
190
+ const inner = w.expr;
191
+ // The operand of `!` is a consumer test, a group, or a scalar primary —
192
+ // a comparison or anything looser must be grouped (`!(a == b)`).
193
+ const grouped = inner.kind === "and" || inner.kind === "or" || (inner.kind === "scalar" && prec(inner.expr) < UNARY);
194
+ return cat("!", paren(where(inner), grouped));
195
+ }
196
+ case "scalar": return expr(w.expr);
197
+ case "op": return op(w);
198
+ }
199
+ }
200
+
201
+ // ---- expressions ------------------------------------------------------------
202
+
203
+ function expr(e: Expr): Out {
204
+ switch (e.kind) {
205
+ case "lit": return [literal(e.value)];
206
+ case "ident": return [e.name];
207
+ case "outer": return ["^".repeat(e.levels) + e.name];
208
+ case "binding": return [e.index];
209
+ case "member": return cat(receiver(e.recv), `.${e.name}`);
210
+ case "call": {
211
+ const args = cat("(", join(e.args.map(expr), ", "), ")");
212
+ return e.recv === null ? cat(e.name, args) : cat(receiver(e.recv), `.${e.name}`, args);
213
+ }
214
+ case "unary": return cat(e.op, operand(e.expr, UNARY));
215
+ // Postfix, tightest: anything below postfix level is grouped (`(a + 1)!`).
216
+ case "required": return cat(operand(e.expr, POSTFIX), "!");
217
+ case "op": return op(e);
218
+ case "binary": {
219
+ const p = prec(e);
220
+ // Left-associative arithmetic keeps an equal-level left operand bare and
221
+ // groups an equal-level right one; a comparison is non-associative, so a
222
+ // nested comparison is grouped on either side.
223
+ return cat(operand(e.left, p === CMP ? p + 1 : p), ` ${e.op} `, operand(e.right, p + 1));
224
+ }
225
+ case "logical": {
226
+ const p = prec(e);
227
+ return cat(operand(e.left, p), e.op === "&&" ? " and " : " or ", operand(e.right, p + 1));
228
+ }
229
+ case "in": return cat(operand(e.left, CMP + 1), " in ", operand(e.right, CMP + 1));
230
+ case "range":
231
+ return cat(e.lo ? operand(e.lo, ADD) : "", e.exclusiveEnd ? "..." : "..", e.hi ? operand(e.hi, ADD) : "");
232
+ }
233
+ }
234
+
235
+ // A child expression, parenthesized when its level is below `min`.
236
+ function operand(e: Expr, min: number): Out {
237
+ return paren(expr(e), prec(e) < min);
238
+ }
239
+
240
+ // The value a `.name` navigates: postfix level, and a number literal is grouped
241
+ // too (`1.size()` would lex as a malformed number).
242
+ function receiver(e: Expr): Out {
243
+ return paren(expr(e), prec(e) < POSTFIX || (e.kind === "lit" && typeof e.value === "number"));
244
+ }
245
+
246
+ function literal(v: string | number | boolean | null): string {
247
+ if (v === null) return "null";
248
+ if (typeof v === "boolean") return v ? "true" : "false";
249
+ if (typeof v === "number") return number(v);
250
+ return quote(v);
251
+ }
252
+
253
+ /** A number in the shortest round-trip form (`String(n)`); the lexer reads every
254
+ * spelling JavaScript produces for a finite number, including `1e+21`. */
255
+ function number(n: number): string {
256
+ return String(n);
257
+ }
258
+
259
+ /** A double-quoted string literal with the GRAMMAR §1 escapes. */
260
+ function quote(s: string): string {
261
+ let out = '"';
262
+ for (const ch of s) {
263
+ switch (ch) {
264
+ case '"': out += '\\"'; break;
265
+ case "\\": out += "\\\\"; break;
266
+ case "\n": out += "\\n"; break;
267
+ case "\t": out += "\\t"; break;
268
+ case "\r": out += "\\r"; break;
269
+ case "\0": out += "\\0"; break;
270
+ default: out += ch;
271
+ }
272
+ }
273
+ return out + '"';
274
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,305 @@
1
+ // Select-alias resolution (SEMANTICS §14). `where` may reference the same body's
2
+ // `select` aliases, and since 0.17 a `select` item may reference the items to
3
+ // its left; the parser keeps the surface form (so the tree can be printed back
4
+ // and reflected on) and only VALIDATES the references, and this module performs
5
+ // the substitution — a pure rewrite the entry points (`run`, `execute`, `oqx`,
6
+ // `runQuery`) apply once before evaluation, so the engine and any pushdown
7
+ // planner see an ordinary `where` and `select` over row fields. Rules:
8
+ // • an alias shadows a same-named row field inside `where` and in the items
9
+ // after it;
10
+ // • an alias's own name inside its own expression is the row field
11
+ // (`name: name.upper()` is not recursive), but a chain of aliases that
12
+ // comes back to one being resolved (`a: b, b: a`) is a cycle → error;
13
+ // • a `select` item may name only the items to its LEFT; a reference to an
14
+ // item to its right is an error (it is a cycle when that item refers back);
15
+ // • an alias whose value is a `collect`/`first`/`single { … }` block may
16
+ // stand alone as a where leaf (a collection in predicate position means
17
+ // non-empty) but not appear inside a `where` expression; in a later `select`
18
+ // item it inlines as a value-position directive (`boss.name`);
19
+ // • nested blocks (consumer bodies, follow blocks, bracket lookups) are their
20
+ // own scopes and are not rewritten against this body's select — each body
21
+ // rewrites against its own.
22
+ //
23
+ // Resolution is NOT idempotent (`name: name.upper() … where name` resolves to
24
+ // `name.upper()`, whose `name` would be substituted again), so it must run
25
+ // exactly once: the entry points do it, `Engine.run` evaluates the query as
26
+ // given. Untouched nodes are returned by reference, so a cached query keeps the
27
+ // identity of every block the rewrite did not reach.
28
+
29
+ import type { Expr, Follow, FollowDestination, OpNode, Query, SelectItem, Subquery, Where } from "./ast.ts";
30
+ import { OqxError } from "./errors.ts";
31
+
32
+ /** How a resolution failure is reported: the parser raises its positioned parse
33
+ * error, the standalone entry point a plain one. */
34
+ export type Fail = (message: string) => never;
35
+
36
+ const failPlain: Fail = (message) => {
37
+ throw new OqxError(message, "parse");
38
+ };
39
+
40
+ /** Resolve every `where` and `select` of a query (its own and every nested
41
+ * block's) against the `select` of the same body. Pure: returns a new tree
42
+ * sharing every node the rewrite did not touch. Throws an `OqxError` (stage
43
+ * `parse`) for an alias cycle, a forward reference, or a block alias used
44
+ * inside a `where` expression — the same conditions the parser rejects, so a
45
+ * parsed query never throws here. */
46
+ export function resolveAliases<T extends Query | Subquery>(node: T): T {
47
+ return resolveBody(node, failPlain);
48
+ }
49
+
50
+ function resolveBody<T extends Query | Subquery>(node: T, fail: Fail): T {
51
+ // `where` is validated and substituted against the raw select first: its
52
+ // cycle report follows the where's own path (`c → a → b → c`), as pinned.
53
+ const where = node.where === null ? null : resolveWhere(node.select, node.where, fail);
54
+ const where2 = where === null ? null : resolveNestedWhere(where, fail);
55
+ const select = resolveSelect(node.select, fail);
56
+ const follow = node.follow === null ? null : resolveFollow(node.follow, fail);
57
+ const from = mapSame(node.from, (e) => resolveExpr(e, fail));
58
+ const orderBy = node.orderBy === null ? null : mapSame(node.orderBy, (o) => {
59
+ const expr = resolveExpr(o.expr, fail);
60
+ return expr === o.expr ? o : { ...o, expr };
61
+ });
62
+ const limit = node.limit === null ? null : resolveExpr(node.limit, fail);
63
+ const offset = node.offset === null ? null : resolveExpr(node.offset, fail);
64
+ const source = node.kind === "query" ? resolveExpr(node.source, fail) : null;
65
+ const same = where2 === node.where && select === node.select && follow === node.follow && from === node.from
66
+ && orderBy === node.orderBy && limit === node.limit && offset === node.offset
67
+ && (node.kind !== "query" || source === node.source);
68
+ if (same) return node;
69
+ const out = { ...node, where: where2, select, follow, from, orderBy, limit, offset };
70
+ if (node.kind === "query") (out as Query).source = source!;
71
+ return out;
72
+ }
73
+
74
+ function resolveOp(op: OpNode, fail: Fail): OpNode {
75
+ const receiver = resolveExpr(op.receiver, fail);
76
+ const sub = resolveBody(op.sub, fail);
77
+ return sub === op.sub && receiver === op.receiver ? op : { ...op, receiver, sub };
78
+ }
79
+
80
+ function resolveFollow(f: Follow, fail: Fail): Follow {
81
+ const destinations = mapSame(f.destinations, (d: FollowDestination) => (d.kind === "op" ? resolveOp(d, fail) : resolveExpr(d, fail)));
82
+ const where = f.where === null ? null : resolveExpr(f.where, fail);
83
+ const frontier = f.frontier === null ? null : resolveExpr(f.frontier, fail);
84
+ const by = f.by === null ? null : resolveExpr(f.by, fail);
85
+ if (destinations === f.destinations && where === f.where && frontier === f.frontier && by === f.by) return f;
86
+ return { ...f, destinations, where, frontier, by };
87
+ }
88
+
89
+ // Nested blocks inside a where tree are their own scopes: resolve each against
90
+ // its own select (a scalar leaf may hold a value-position directive).
91
+ function resolveNestedWhere(w: Where, fail: Fail): Where {
92
+ switch (w.kind) {
93
+ case "and": case "or": {
94
+ const parts = mapSame(w.parts, (p) => resolveNestedWhere(p, fail));
95
+ return parts === w.parts ? w : { ...w, parts };
96
+ }
97
+ case "not": {
98
+ const expr = resolveNestedWhere(w.expr, fail);
99
+ return expr === w.expr ? w : { ...w, expr };
100
+ }
101
+ case "scalar": {
102
+ const expr = resolveExpr(w.expr, fail);
103
+ return expr === w.expr ? w : { ...w, expr };
104
+ }
105
+ case "op": return resolveOp(w, fail);
106
+ }
107
+ }
108
+
109
+ /** Resolve the nested blocks (value-position directives) inside an expression,
110
+ * each against its own select; the expression's own idents are left alone. */
111
+ function resolveExpr(e: Expr, fail: Fail): Expr {
112
+ return mapExpr(e, (x) => (x.kind === "op" ? resolveOp(x, fail) : null));
113
+ }
114
+
115
+ /** Rebuild `e` bottom-up, letting `f` replace a node (return `null` to keep it;
116
+ * a replaced node's children are not visited — `f` owns them). Untouched nodes
117
+ * are returned by reference. */
118
+ function mapExpr(e: Expr, f: (e: Expr) => Expr | null): Expr {
119
+ const hit = f(e);
120
+ if (hit !== null) return hit;
121
+ const go = (x: Expr): Expr => mapExpr(x, f);
122
+ switch (e.kind) {
123
+ case "member": { const recv = go(e.recv); return recv === e.recv ? e : { ...e, recv }; }
124
+ case "call": {
125
+ const recv = e.recv ? go(e.recv) : null;
126
+ const args = mapSame(e.args, go);
127
+ return recv === e.recv && args === e.args ? e : { ...e, recv, args };
128
+ }
129
+ case "unary": case "required": { const expr = go(e.expr); return expr === e.expr ? e : { ...e, expr }; }
130
+ case "binary": case "logical": case "in": {
131
+ const left = go(e.left);
132
+ const right = go(e.right);
133
+ return left === e.left && right === e.right ? e : { ...e, left, right };
134
+ }
135
+ case "range": {
136
+ const lo = e.lo ? go(e.lo) : null;
137
+ const hi = e.hi ? go(e.hi) : null;
138
+ return lo === e.lo && hi === e.hi ? e : { ...e, lo, hi };
139
+ }
140
+ case "op": { // the receiver is read in this scope; the block is its own scope
141
+ const receiver = go(e.receiver);
142
+ return receiver === e.receiver ? e : { ...e, receiver };
143
+ }
144
+ default: return e; // lit, ident, binding, outer
145
+ }
146
+ }
147
+
148
+ // An item other items may name: a `collect` item, or a named non-lift field.
149
+ function aliasName(it: SelectItem): string | null {
150
+ if (it.kind === "collect") return it.name === "" ? null : it.name;
151
+ return it.lift === 0 && it.name !== "" ? it.name : null;
152
+ }
153
+
154
+ // The alias names an item's own expression (the receiver, for a block) mentions
155
+ // as bare identifiers, excluding its own name (the row field).
156
+ function aliasRefs(it: SelectItem, index: ReadonlyMap<string, number>): string[] {
157
+ const own = aliasName(it);
158
+ const out: string[] = [];
159
+ const visit = (e: Expr): void => {
160
+ mapExpr(e, (x) => {
161
+ if (x.kind === "ident" && x.name !== own && index.has(x.name) && !out.includes(x.name)) out.push(x.name);
162
+ return null;
163
+ });
164
+ };
165
+ visit(it.kind === "collect" ? it.op.receiver : it.expr);
166
+ return out;
167
+ }
168
+
169
+ /** Resolve one body's `select` items left to right (this body only; each item's
170
+ * nested blocks are resolved against their own select). An item's bare
171
+ * identifier that names an item to its left is replaced by that item's resolved
172
+ * value — a block alias inlines as a value-position directive — and the item's
173
+ * own name is the row field. A reference to an item to its right fails: as a
174
+ * cycle when that item refers back, else as a forward reference. Exported for
175
+ * the parser's validation pass. */
176
+ export function resolveSelect(select: readonly SelectItem[], fail: Fail = failPlain): SelectItem[] {
177
+ const index = new Map<string, number>();
178
+ select.forEach((it, i) => { const n = aliasName(it); if (n !== null) index.set(n, i); });
179
+ if (index.size === 0) {
180
+ return mapSame(select as SelectItem[], (it) => resolveItemBlocks(it, fail));
181
+ }
182
+ const resolved = new Map<string, Expr>();
183
+ let out: SelectItem[] | null = null;
184
+ for (let i = 0; i < select.length; i++) {
185
+ const it = select[i]!;
186
+ const own = aliasName(it);
187
+ const subst = (e: Expr): Expr => mapExpr(e, (x) => {
188
+ if (x.kind !== "ident") return null;
189
+ if (x.name === own) return x;
190
+ const r = resolved.get(x.name);
191
+ if (r) return r;
192
+ const j = index.get(x.name);
193
+ if (j === undefined || j <= i) return null;
194
+ const back = own === null ? null : cyclePath(select, index, j, own);
195
+ if (back) fail(`select aliases form a cycle: ${[own, ...back].join(" → ")} — an alias cannot depend on itself`);
196
+ fail(`select alias '${x.name}' is used before it is defined — a select item may reference only the items to its left`);
197
+ });
198
+ let r: SelectItem;
199
+ if (it.kind === "collect") {
200
+ const receiver = subst(it.op.receiver);
201
+ const op = resolveOp(receiver === it.op.receiver ? it.op : { ...it.op, receiver }, fail);
202
+ r = op === it.op ? it : { ...it, op };
203
+ if (own !== null) resolved.set(own, op);
204
+ } else {
205
+ const expr = resolveExpr(subst(it.expr), fail);
206
+ r = expr === it.expr ? it : expr.kind === "op"
207
+ ? { kind: "collect", span: it.span, name: it.name, op: expr }
208
+ : { ...it, expr };
209
+ if (own !== null) resolved.set(own, expr);
210
+ }
211
+ if (r !== it) { out ??= select.slice(); out[i] = r; }
212
+ }
213
+ return out ?? (select as SelectItem[]);
214
+ }
215
+
216
+ // The nested blocks of an item, resolved against their own select.
217
+ function resolveItemBlocks(it: SelectItem, fail: Fail): SelectItem {
218
+ if (it.kind === "collect") {
219
+ const op = resolveOp(it.op, fail);
220
+ return op === it.op ? it : { ...it, op };
221
+ }
222
+ const expr = resolveExpr(it.expr, fail);
223
+ return expr === it.expr ? it : { ...it, expr };
224
+ }
225
+
226
+ // The alias path from item `from` back to the alias `to`, if the raw reference
227
+ // graph has one (`[b, a]` for `a: b, b: a` from `b` back to `a`).
228
+ function cyclePath(select: readonly SelectItem[], index: ReadonlyMap<string, number>, from: number, to: string): string[] | null {
229
+ const seen = new Set<number>();
230
+ const go = (i: number, path: string[]): string[] | null => {
231
+ if (seen.has(i)) return null;
232
+ seen.add(i);
233
+ const it = select[i]!;
234
+ const name = aliasName(it)!;
235
+ for (const ref of aliasRefs(it, index)) {
236
+ if (ref === to) return [...path, name, to];
237
+ const hit = go(index.get(ref)!, [...path, name]);
238
+ if (hit) return hit;
239
+ }
240
+ return null;
241
+ };
242
+ return go(from, []);
243
+ }
244
+
245
+ /** Resolve one body's `where` against its `select` (this body only; nested
246
+ * blocks are left as they are). Exported for the parser's validation pass. */
247
+ export function resolveWhere(select: readonly SelectItem[], where: Where, fail: Fail = failPlain): Where {
248
+ if (select.length === 0) return where;
249
+ const aliases = new Map<string, SelectItem>();
250
+ for (const it of select) {
251
+ const n = aliasName(it);
252
+ if (n !== null) aliases.set(n, it);
253
+ }
254
+ if (aliases.size === 0) return where;
255
+ const resolving: string[] = [];
256
+ const subst = (e: Expr): Expr => mapExpr(e, (x) => {
257
+ if (x.kind !== "ident") return null;
258
+ const a = aliases.get(x.name);
259
+ if (!a) return null;
260
+ if (resolving[resolving.length - 1] === x.name) return x; // its own name inside its own expression: the row field
261
+ if (resolving.includes(x.name)) {
262
+ const cycle = [...resolving.slice(resolving.indexOf(x.name)), x.name].join(" → ");
263
+ fail(`select aliases form a cycle: ${cycle} — an alias used in \`where\` cannot depend on itself`);
264
+ }
265
+ if (a.kind === "collect") {
266
+ fail(`select alias '${x.name}' is a ${a.op.op} { … } block — in \`where\` it can only stand alone as a non-empty test, not inside an expression`);
267
+ }
268
+ resolving.push(x.name);
269
+ const out = subst(a.expr);
270
+ resolving.pop();
271
+ return out;
272
+ });
273
+ const walk = (w: Where): Where => {
274
+ switch (w.kind) {
275
+ case "and": case "or": { const parts = mapSame(w.parts, walk); return parts === w.parts ? w : { ...w, parts }; }
276
+ case "not": { const expr = walk(w.expr); return expr === w.expr ? w : { ...w, expr }; }
277
+ case "scalar": {
278
+ if (w.expr.kind === "ident") {
279
+ const a = aliases.get(w.expr.name);
280
+ if (a?.kind === "collect") return a.op; // a collection in predicate position: non-empty
281
+ }
282
+ const expr = subst(w.expr);
283
+ return expr === w.expr ? w : { ...w, expr };
284
+ }
285
+ case "op": { // the receiver is read in this scope; the block is its own scope
286
+ const receiver = subst(w.receiver);
287
+ return receiver === w.receiver ? w : { ...w, receiver };
288
+ }
289
+ }
290
+ };
291
+ return walk(where);
292
+ }
293
+
294
+ // `map` that returns the input array itself when no element changed.
295
+ function mapSame<T>(xs: readonly T[], f: (x: T) => T): T[] {
296
+ let out: T[] | null = null;
297
+ for (let i = 0; i < xs.length; i++) {
298
+ const y = f(xs[i]!);
299
+ if (y !== xs[i]) {
300
+ out ??= xs.slice();
301
+ out[i] = y;
302
+ }
303
+ }
304
+ return out ?? (xs as T[]);
305
+ }
package/src/semantics.ts CHANGED
@@ -252,7 +252,7 @@ export function parseRangeString(s: string): OqxRange | null {
252
252
  // converts it explicitly into a collection of ENTRY values, each `{ key, value }`
253
253
  // tagged (non-enumerably, so JSON/deepEqual see a plain record) so the engine
254
254
  // can recognize one: when an entry becomes a query scope, the scope's ROW is
255
- // the property's value (`$value`, bare names) and `$key` is scope metadata.
255
+ // the property's value (`$it`, bare names) and `$key` is scope metadata.
256
256
  // Arrays yield numeric index keys; a Map yields its entries; null/scalars yield
257
257
  // nothing. (Arrays never expose an implicit `$key` — `entries(arr)` is how you
258
258
  // ask for the index.)
package/src/version.ts ADDED
@@ -0,0 +1,4 @@
1
+ /** The OQX language version (`spec/oqx/VERSION`, `major.minor`) this implementation
2
+ * conforms to — distinct from the package's own semver, whose `major.minor` it
3
+ * equals. Stamped on every `toJSON` document. */
4
+ export const LANGUAGE_VERSION = "0.18";