@omgbase/oqx 0.6.0 → 0.8.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 +88 -15
- package/dist/adapters/sqlite.js +2 -2
- package/dist/adapters/sqlite.js.map +1 -1
- package/dist/ast.d.ts +6 -0
- package/dist/ast.d.ts.map +1 -1
- package/dist/context.d.ts +4 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +0 -3
- package/dist/context.js.map +1 -1
- package/dist/engine.d.ts +2 -2
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +59 -49
- package/dist/engine.js.map +1 -1
- package/dist/parser.js +84 -32
- package/dist/parser.js.map +1 -1
- package/dist/plan.d.ts +2 -1
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +9 -1
- package/dist/plan.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/sqlite.ts +2 -2
- package/src/ast.ts +9 -3
- package/src/context.ts +4 -8
- package/src/engine.ts +64 -45
- package/src/parser.ts +76 -25
- package/src/plan.ts +9 -1
package/src/engine.ts
CHANGED
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
// `first`/`single` stop early when the result is unordered; `count` never
|
|
7
7
|
// materializes rows; and within an `&&` the cheap scalar leaves are evaluated
|
|
8
8
|
// before expensive consumer-op leaves (which each drive a nested traversal).
|
|
9
|
+
//
|
|
10
|
+
// Name resolution is strictly lexical and LOCAL: a bare identifier is read from
|
|
11
|
+
// the current scope only, and an enclosing scope is reached solely through an
|
|
12
|
+
// explicit `^name` (exactly one scope out per caret). There is no implicit
|
|
13
|
+
// fall-through from an inner scope to an outer one — see `resolveIn`.
|
|
9
14
|
|
|
10
15
|
import type {
|
|
11
|
-
Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow,
|
|
16
|
+
Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow, Subquery,
|
|
12
17
|
} from "./ast.ts";
|
|
13
18
|
import type { DataContext } from "./context.ts";
|
|
14
19
|
import { DefaultContext } from "./context.ts";
|
|
@@ -30,6 +35,11 @@ export interface Engine {
|
|
|
30
35
|
run(query: Query, bindings: readonly unknown[]): OqxResult;
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
// One query scope: the row under evaluation plus the chain of enclosing scopes
|
|
39
|
+
// that `^` walks. The root scope (parent === null) has no row; its names are the
|
|
40
|
+
// context's named roots. `lifts` holds values bound INTO this scope by `^name:`
|
|
41
|
+
// items in nested blocks; `meta` holds recursion intrinsics for a follow
|
|
42
|
+
// occurrence.
|
|
33
43
|
interface Scope {
|
|
34
44
|
row: unknown;
|
|
35
45
|
parent: Scope | null;
|
|
@@ -41,6 +51,10 @@ interface Scope {
|
|
|
41
51
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
42
52
|
const HARD_DEPTH_CAP = 8;
|
|
43
53
|
|
|
54
|
+
// What a scope projects to: the select list plus the `values` mode flag. Both
|
|
55
|
+
// `Query` and `Subquery` carry this shape.
|
|
56
|
+
type Projection = Pick<Subquery, "select" | "values">;
|
|
57
|
+
|
|
44
58
|
export class InMemoryEngine implements Engine {
|
|
45
59
|
private ctx: DataContext;
|
|
46
60
|
|
|
@@ -81,8 +95,8 @@ export class InMemoryEngine implements Engine {
|
|
|
81
95
|
}
|
|
82
96
|
}
|
|
83
97
|
this.sortScopes(kept, query.orderBy);
|
|
84
|
-
if (query.distinct) kept = this.dedupByProjection(kept, query
|
|
85
|
-
return this.shape(query.consumer, kept, query
|
|
98
|
+
if (query.distinct) kept = this.dedupByProjection(kept, query);
|
|
99
|
+
return this.shape(query.consumer, kept, query);
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
// A where match that needs no lift capture (exists/count fast paths).
|
|
@@ -108,20 +122,20 @@ export class InMemoryEngine implements Engine {
|
|
|
108
122
|
let scopes: Scope[] = occ.map((o) => ({ row: o.row, parent: root, bindings: root.bindings, lifts: {}, meta: o.meta }));
|
|
109
123
|
if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
110
124
|
this.sortScopes(scopes, query.orderBy);
|
|
111
|
-
if (query.distinct) scopes = this.dedupByProjection(scopes, query
|
|
112
|
-
return this.shape(query.consumer, scopes, query
|
|
125
|
+
if (query.distinct) scopes = this.dedupByProjection(scopes, query);
|
|
126
|
+
return this.shape(query.consumer, scopes, query);
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
// Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
|
|
116
130
|
// distinct projection, preserving order. An empty projection dedups by row
|
|
117
131
|
// identity (so `count distinct { }` counts distinct rows).
|
|
118
|
-
private dedupByProjection(scopes: Scope[],
|
|
132
|
+
private dedupByProjection(scopes: Scope[], proj: Projection): Scope[] {
|
|
119
133
|
const seen = new Set<string>();
|
|
120
134
|
const out: Scope[] = [];
|
|
121
135
|
for (const s of scopes) {
|
|
122
|
-
const key = select.length === 0
|
|
136
|
+
const key = proj.select.length === 0
|
|
123
137
|
? `i:${String(this.ctx.identity(s.row))}`
|
|
124
|
-
: `p:${stableStringify(this.projectRow(
|
|
138
|
+
: `p:${stableStringify(this.projectRow(proj, s))}`;
|
|
125
139
|
if (seen.has(key)) continue;
|
|
126
140
|
seen.add(key);
|
|
127
141
|
out.push(s);
|
|
@@ -203,27 +217,33 @@ export class InMemoryEngine implements Engine {
|
|
|
203
217
|
|
|
204
218
|
// ---- consumer shaping -----------------------------------------------------
|
|
205
219
|
|
|
206
|
-
private shape(consumer: Query["consumer"], scopes: Scope[],
|
|
220
|
+
private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
|
|
207
221
|
switch (consumer) {
|
|
208
222
|
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
209
223
|
case "count": return { consumer, count: scopes.length };
|
|
210
|
-
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(
|
|
211
|
-
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(
|
|
224
|
+
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
|
|
225
|
+
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
|
|
212
226
|
case "single":
|
|
213
227
|
if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
|
|
214
|
-
return { consumer, row: scopes.length > 0 ? this.projectRow(
|
|
228
|
+
return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
|
|
215
229
|
}
|
|
216
230
|
}
|
|
217
231
|
|
|
218
|
-
|
|
232
|
+
// The per-row result: the raw row (empty projection), the single item's value
|
|
233
|
+
// itself (`values` mode), or a `{ name: value }` record.
|
|
234
|
+
private projectRow(proj: Projection, scope: Scope): unknown {
|
|
235
|
+
const { select } = proj;
|
|
219
236
|
if (select.length === 0) return scope.row;
|
|
237
|
+
if (proj.values) return this.itemValue(select[0]!, scope);
|
|
220
238
|
const out: Record<string, unknown> = {};
|
|
221
|
-
for (const item of select)
|
|
222
|
-
out[item.name] = item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
|
|
223
|
-
}
|
|
239
|
+
for (const item of select) out[item.name] = this.itemValue(item, scope);
|
|
224
240
|
return out;
|
|
225
241
|
}
|
|
226
242
|
|
|
243
|
+
private itemValue(item: SelectItem, scope: Scope): unknown {
|
|
244
|
+
return item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
|
|
245
|
+
}
|
|
246
|
+
|
|
227
247
|
// ---- where evaluation -----------------------------------------------------
|
|
228
248
|
|
|
229
249
|
private evalWhere(w: Where, scope: Scope): boolean {
|
|
@@ -261,7 +281,7 @@ export class InMemoryEngine implements Engine {
|
|
|
261
281
|
}
|
|
262
282
|
if (op.op === "count") {
|
|
263
283
|
let rows = this.matchRows(op, scope);
|
|
264
|
-
if (op.distinct) rows = this.dedupByProjection(rows, op.sub
|
|
284
|
+
if (op.distinct) rows = this.dedupByProjection(rows, op.sub);
|
|
265
285
|
const n = rows.length;
|
|
266
286
|
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
267
287
|
}
|
|
@@ -300,13 +320,13 @@ export class InMemoryEngine implements Engine {
|
|
|
300
320
|
scopes = this.matchRows(op, scope);
|
|
301
321
|
}
|
|
302
322
|
this.sortScopes(scopes, sub.orderBy);
|
|
303
|
-
if (op.distinct) scopes = this.dedupByProjection(scopes, sub
|
|
323
|
+
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
304
324
|
switch (op.op) {
|
|
305
|
-
case "collect": return scopes.map((s) => this.projectRow(sub
|
|
306
|
-
case "first": return scopes.length > 0 ? this.projectRow(sub
|
|
325
|
+
case "collect": return scopes.map((s) => this.projectRow(sub, s));
|
|
326
|
+
case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
|
|
307
327
|
case "single":
|
|
308
328
|
if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
|
|
309
|
-
return scopes.length > 0 ? this.projectRow(sub
|
|
329
|
+
return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
|
|
310
330
|
default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
|
|
311
331
|
}
|
|
312
332
|
}
|
|
@@ -341,11 +361,13 @@ export class InMemoryEngine implements Engine {
|
|
|
341
361
|
switch (e.kind) {
|
|
342
362
|
case "lit": return e.value;
|
|
343
363
|
case "binding": return scope.bindings[e.index];
|
|
344
|
-
case "ident": return this.
|
|
364
|
+
case "ident": return this.resolveIn(e.name, scope);
|
|
345
365
|
case "outer": {
|
|
366
|
+
// `^name` reads from EXACTLY `levels` scopes out — the target scope is
|
|
367
|
+
// resolved locally, never climbed further. Past the root it is absent.
|
|
346
368
|
let s: Scope | null = scope;
|
|
347
369
|
for (let i = 0; i < e.levels && s; i++) s = s.parent;
|
|
348
|
-
return s ? this.
|
|
370
|
+
return s ? this.resolveIn(e.name, s) : undefined;
|
|
349
371
|
}
|
|
350
372
|
case "member": {
|
|
351
373
|
const r = this.evalExpr(e.recv, scope);
|
|
@@ -377,28 +399,25 @@ export class InMemoryEngine implements Engine {
|
|
|
377
399
|
}
|
|
378
400
|
}
|
|
379
401
|
|
|
380
|
-
// Resolve a
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
private has(row: unknown, name: string): boolean {
|
|
400
|
-
if (this.ctx.has) return this.ctx.has(row, name);
|
|
401
|
-
return row != null && typeof row === "object" && name in (row as object);
|
|
402
|
+
// Resolve a name against ONE scope — never its ancestors. A scope provides,
|
|
403
|
+
// in order: `$value` (the scope's row itself — the current item, whatever its
|
|
404
|
+
// type, so scalar collections are queryable; absent at the root, which has no
|
|
405
|
+
// row); the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
|
|
406
|
+
// values lifted into it by `^name:` items; then either the row's own property
|
|
407
|
+
// or, for the root scope (no row), the context's named roots.
|
|
408
|
+
//
|
|
409
|
+
// A name the scope lacks is simply absent (undefined). It does NOT fall
|
|
410
|
+
// through to an enclosing scope, so a query's meaning never depends on which
|
|
411
|
+
// properties an inner row happens to have: adding a same-named property to an
|
|
412
|
+
// inner row cannot capture an outer reference, and an outer reference is
|
|
413
|
+
// always spelled explicitly as `^name`. Present-but-falsy values (null, false,
|
|
414
|
+
// 0, "") need no special case — there is no "absent, so look outward" rule.
|
|
415
|
+
private resolveIn(name: string, scope: Scope): unknown {
|
|
416
|
+
if (name === "$value") return scope.parent === null ? undefined : scope.row;
|
|
417
|
+
if (RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
|
|
418
|
+
if (scope.lifts && name in scope.lifts) return scope.lifts[name];
|
|
419
|
+
if (scope.parent === null) return this.ctx.root(name);
|
|
420
|
+
return this.ctx.get(scope.row, name);
|
|
402
421
|
}
|
|
403
422
|
|
|
404
423
|
private evalCall(e: Extract<Expr, { kind: "call" }>, scope: Scope): unknown {
|
package/src/parser.ts
CHANGED
|
@@ -25,7 +25,7 @@ const CONSUMERS = new Set<string>(["collect", "exists", "count", "first", "singl
|
|
|
25
25
|
// before them rather than consume them as its high bound.
|
|
26
26
|
const CLAUSE_WORDS = new Set<string>([
|
|
27
27
|
"collect", "exists", "count", "first", "single",
|
|
28
|
-
"order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in",
|
|
28
|
+
"order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
|
|
29
29
|
]);
|
|
30
30
|
const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
31
31
|
const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
@@ -49,6 +49,7 @@ interface BodyClauses {
|
|
|
49
49
|
orderBy: OrderSpec[] | null;
|
|
50
50
|
follow: Follow | null;
|
|
51
51
|
distinct: boolean;
|
|
52
|
+
values: boolean;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
class Parser {
|
|
@@ -89,6 +90,7 @@ class Parser {
|
|
|
89
90
|
consumer: directive.op,
|
|
90
91
|
follow: directive.sub.follow,
|
|
91
92
|
distinct: directive.distinct ?? false,
|
|
93
|
+
values: directive.sub.values ?? false,
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
96
|
if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
|
|
@@ -108,6 +110,7 @@ class Parser {
|
|
|
108
110
|
consumer: "collect",
|
|
109
111
|
follow: body.follow,
|
|
110
112
|
distinct: body.distinct,
|
|
113
|
+
values: body.values,
|
|
111
114
|
};
|
|
112
115
|
}
|
|
113
116
|
|
|
@@ -124,6 +127,7 @@ class Parser {
|
|
|
124
127
|
let orderBy: OrderSpec[] | null = null;
|
|
125
128
|
let follow: Follow | null = null;
|
|
126
129
|
let distinct = false;
|
|
130
|
+
let values = false;
|
|
127
131
|
let sawWhere = false, sawSelect = false, sawOrder = false;
|
|
128
132
|
|
|
129
133
|
while (!this.at("eof") && !this.at("rbrace")) {
|
|
@@ -148,7 +152,7 @@ class Parser {
|
|
|
148
152
|
if (sawSelect) this.fail("duplicate projection");
|
|
149
153
|
sawSelect = true;
|
|
150
154
|
if (this.at("kw")) { this.next(); if (this.at("ident", "distinct")) { this.next(); distinct = true; } } // consume `select` + optional `distinct`; a leading `^` is part of the item
|
|
151
|
-
select = this.
|
|
155
|
+
({ items: select, values } = this.parseProjection());
|
|
152
156
|
continue;
|
|
153
157
|
}
|
|
154
158
|
if (orderByAllowed && this.atOrderBy()) {
|
|
@@ -167,12 +171,12 @@ class Parser {
|
|
|
167
171
|
if (this.at("ident") || this.at("binding")) {
|
|
168
172
|
if (sawSelect) this.fail("duplicate projection (an implicit select cannot follow a `select`)");
|
|
169
173
|
sawSelect = true;
|
|
170
|
-
select = this.
|
|
174
|
+
({ items: select, values } = this.parseProjection());
|
|
171
175
|
continue;
|
|
172
176
|
}
|
|
173
177
|
this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/follow`);
|
|
174
178
|
}
|
|
175
|
-
return { froms, where, select, orderBy, follow, distinct };
|
|
179
|
+
return { froms, where, select, orderBy, follow, distinct, values };
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
// Decide, by syntactic shape only, whether a leading unkeyworded run is a
|
|
@@ -263,15 +267,28 @@ class Parser {
|
|
|
263
267
|
return follow;
|
|
264
268
|
}
|
|
265
269
|
|
|
266
|
-
// A receiver/source: a `${…}` binding, or a dotted identifier navigation chain
|
|
270
|
+
// A receiver/source: a `${…}` binding, or a dotted identifier navigation chain
|
|
271
|
+
// whose head may be an outer reference (`^rel`, `^^root.rel`) — since a bare
|
|
272
|
+
// name is the current row's own property, an enclosing row's relation or a
|
|
273
|
+
// named root is only reachable as a receiver through `^`.
|
|
267
274
|
private parseReceiver(): Expr {
|
|
268
275
|
if (this.at("binding")) return { kind: "binding", index: this.next().index! };
|
|
276
|
+
const levels = this.parseCarets();
|
|
269
277
|
if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
|
|
270
|
-
return this.parseNavFrom(this.next()).expr;
|
|
278
|
+
return this.parseNavFrom(this.next(), levels).expr;
|
|
271
279
|
}
|
|
272
280
|
|
|
273
|
-
|
|
274
|
-
|
|
281
|
+
// Consume a run of `^` and return its length (0 when there is none).
|
|
282
|
+
private parseCarets(): number {
|
|
283
|
+
let levels = 0;
|
|
284
|
+
while (this.at("caret")) { this.next(); levels++; }
|
|
285
|
+
return levels;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A dotted navigation chain from `head`; `levels` > 0 makes the head an outer
|
|
289
|
+
// reference read exactly that many scopes out.
|
|
290
|
+
private parseNavFrom(head: Token, levels = 0): { expr: Expr; name: string } {
|
|
291
|
+
let expr: Expr = levels > 0 ? { kind: "outer", levels, name: head.value } : { kind: "ident", name: head.value };
|
|
275
292
|
let name = head.value;
|
|
276
293
|
while (this.at("dot")) {
|
|
277
294
|
this.next();
|
|
@@ -283,21 +300,36 @@ class Parser {
|
|
|
283
300
|
}
|
|
284
301
|
|
|
285
302
|
// ---- select ---------------------------------------------------------------
|
|
286
|
-
|
|
303
|
+
// A projection list, optionally followed by the `values` mode word. Under
|
|
304
|
+
// `values` the list must be exactly one item, which need not be named: the
|
|
305
|
+
// row's result IS that value (no `{ name: value }` record), so a name would be
|
|
306
|
+
// meaningless. Without `values`, every item needs a key — a bare/dotted
|
|
307
|
+
// navigation supplies its own (the last segment); any other expression must be
|
|
308
|
+
// aliased (`name: expr`).
|
|
309
|
+
private parseProjection(): { items: SelectItem[]; values: boolean } {
|
|
287
310
|
const items = [this.parseSelectItem()];
|
|
288
311
|
while (this.at("comma")) { this.next(); items.push(this.parseSelectItem()); }
|
|
289
|
-
|
|
312
|
+
let values = false;
|
|
313
|
+
if (this.at("ident", "values")) {
|
|
314
|
+
this.next();
|
|
315
|
+
values = true;
|
|
316
|
+
if (items.length !== 1) this.fail("`values` projects exactly one expression (got " + items.length + ")");
|
|
317
|
+
const only = items[0]!;
|
|
318
|
+
if (only.kind === "field" && only.lift > 0) this.fail("a lift (^name: …) cannot be combined with `values`");
|
|
319
|
+
} else {
|
|
320
|
+
for (const it of items) {
|
|
321
|
+
if (it.name === "") this.fail("a projection item that is not a plain name needs an alias (`name: expr`) unless it is followed by `values`");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { items, values };
|
|
290
325
|
}
|
|
291
326
|
|
|
292
327
|
private parseSelectItem(): SelectItem {
|
|
293
328
|
// Leading `^`s mark a lift; the count is how many scopes out it binds.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (this.at("colon")) {
|
|
299
|
-
this.next();
|
|
300
|
-
const name = nameTok.value;
|
|
329
|
+
const lift = this.parseCarets();
|
|
330
|
+
if (this.at("ident") && this.peekAt(1)?.type === "colon") {
|
|
331
|
+
const name = this.next().value;
|
|
332
|
+
this.next(); // ':'
|
|
301
333
|
const op = this.tryOp();
|
|
302
334
|
if (op) {
|
|
303
335
|
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
@@ -309,9 +341,12 @@ class Parser {
|
|
|
309
341
|
const expr = this.parseValueExpr();
|
|
310
342
|
return { kind: "field", name, expr, lift };
|
|
311
343
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
344
|
+
if (!this.at("ident") && !this.canStartValue()) this.fail("expected a projection name");
|
|
345
|
+
// Unaliased item: a bare/dotted navigation keys by its last segment; any
|
|
346
|
+
// other expression is unnamed ("") — legal only under `values` (checked by
|
|
347
|
+
// parseProjection, which sees the whole list).
|
|
348
|
+
const expr = this.parseValueExpr();
|
|
349
|
+
return { kind: "field", name: navKey(expr) ?? "", expr, lift };
|
|
315
350
|
}
|
|
316
351
|
|
|
317
352
|
// ---- order by -------------------------------------------------------------
|
|
@@ -393,8 +428,11 @@ class Parser {
|
|
|
393
428
|
const start = this.pos;
|
|
394
429
|
let receiver: Expr;
|
|
395
430
|
if (this.at("binding")) receiver = { kind: "binding", index: this.next().index! };
|
|
396
|
-
else if (this.at("ident")
|
|
397
|
-
|
|
431
|
+
else if (this.at("ident") || this.at("caret")) {
|
|
432
|
+
const levels = this.parseCarets();
|
|
433
|
+
if (!this.at("ident")) { this.pos = start; return null; }
|
|
434
|
+
receiver = this.parseNavFrom(this.next(), levels).expr;
|
|
435
|
+
} else return null;
|
|
398
436
|
|
|
399
437
|
if (this.at("ident") && CONSUMERS.has(this.peek().value)) {
|
|
400
438
|
const after = this.peekAt(1);
|
|
@@ -418,7 +456,10 @@ class Parser {
|
|
|
418
456
|
|
|
419
457
|
private parseSubquery(): { sub: Subquery; distinct: boolean } {
|
|
420
458
|
const body = this.parseBody(true);
|
|
421
|
-
return {
|
|
459
|
+
return {
|
|
460
|
+
sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values },
|
|
461
|
+
distinct: body.distinct,
|
|
462
|
+
};
|
|
422
463
|
}
|
|
423
464
|
|
|
424
465
|
// ---- expression Pratt parser ----------------------------------------------
|
|
@@ -548,8 +589,7 @@ class Parser {
|
|
|
548
589
|
// expression position, `^` reads an enclosing row's field even when the
|
|
549
590
|
// current row shadows the name.)
|
|
550
591
|
if (t.type === "caret") {
|
|
551
|
-
|
|
552
|
-
while (this.at("caret")) { this.next(); levels++; }
|
|
592
|
+
const levels = this.parseCarets();
|
|
553
593
|
if (!this.at("ident")) this.fail("expected an identifier after '^' (an outer reference)");
|
|
554
594
|
return { kind: "outer", levels, name: this.next().value };
|
|
555
595
|
}
|
|
@@ -573,3 +613,14 @@ class Parser {
|
|
|
573
613
|
this.fail(`unexpected ${this.tokDesc()} — expected a value`);
|
|
574
614
|
}
|
|
575
615
|
}
|
|
616
|
+
|
|
617
|
+
// The default key of an unaliased projection item: the last segment of a bare /
|
|
618
|
+
// dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
|
|
619
|
+
function navKey(e: Expr): string | null {
|
|
620
|
+
switch (e.kind) {
|
|
621
|
+
case "ident": return e.name;
|
|
622
|
+
case "outer": return e.name;
|
|
623
|
+
case "member": return e.name;
|
|
624
|
+
default: return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
package/src/plan.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
// Deliberately conservative: only positive scalar leaves are pushable. Consumer
|
|
9
9
|
// ops (exists/count/collect), negation, and disjunction stay residual — an
|
|
10
10
|
// adapter that wants to push those can special-case them itself.
|
|
11
|
+
//
|
|
12
|
+
// Because a bare identifier resolves against the current row ONLY (it never
|
|
13
|
+
// climbs to an enclosing scope or a named root), an `ident` in a top-level
|
|
14
|
+
// `where` is unambiguously a column of the scanned rows and is safe to push. An
|
|
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
|
|
17
|
+
// gate idents on their known column set, which never includes it.
|
|
11
18
|
|
|
12
19
|
import type { Query, Where, Expr } from "./ast.ts";
|
|
13
20
|
|
|
@@ -49,7 +56,8 @@ export function constValue(e: Expr, params: readonly unknown[]): unknown {
|
|
|
49
56
|
throw new Error("constValue: not a constant expression");
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
/** Recognize `field == const` / `const == field` (
|
|
59
|
+
/** Recognize `field == const` / `const == field` (a bare identifier is always a
|
|
60
|
+
* column of the current row — see the module note). */
|
|
53
61
|
export function asEquality(e: Expr): { field: string; value: Expr } | null {
|
|
54
62
|
if (e.kind !== "binary" || e.op !== "==") return null;
|
|
55
63
|
if (e.left.kind === "ident" && isConst(e.right)) return { field: e.left.name, value: e.right };
|