@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/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.select);
85
- return this.shape(query.consumer, kept, query.select);
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.select);
112
- return this.shape(query.consumer, scopes, query.select);
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[], select: SelectItem[]): 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(select, s))}`;
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[], select: SelectItem[]): OqxResult {
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(select, s)) };
211
- case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(select, scopes[0]!) : null };
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(select, scopes[0]!) : null };
228
+ return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
215
229
  }
216
230
  }
217
231
 
218
- private projectRow(select: SelectItem[], scope: Scope): unknown {
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.select);
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.select);
323
+ if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
304
324
  switch (op.op) {
305
- case "collect": return scopes.map((s) => this.projectRow(sub.select, s));
306
- case "first": return scopes.length > 0 ? this.projectRow(sub.select, scopes[0]!) : null;
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.select, scopes[0]!) : null;
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.resolveFrom(e.name, scope);
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.resolveFrom(e.name, s) : undefined;
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 bare name starting at `start` and climbing enclosing scopes: `^`
381
- // outer references reuse this after skipping the requested number of scopes.
382
- private resolveFrom(name: string, start: Scope): unknown {
383
- if (RECUR.has(name)) {
384
- for (let s: Scope | null = start; s; s = s.parent) if (s.meta && name in s.meta) return s.meta[name];
385
- return undefined;
386
- }
387
- for (let s: Scope | null = start; s; s = s.parent) {
388
- if (s.lifts && name in s.lifts) return s.lifts[name];
389
- if (s.parent === null) {
390
- const v = this.ctx.root(name);
391
- if (v !== undefined) return v;
392
- } else if (this.has(s.row, name)) {
393
- return this.ctx.get(s.row, name);
394
- }
395
- }
396
- return undefined;
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.parseSelectItems();
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.parseSelectItems();
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
- private parseNavFrom(head: Token): { expr: Expr; name: string } {
274
- let expr: Expr = { kind: "ident", name: head.value };
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
- private parseSelectItems(): SelectItem[] {
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
- return items;
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
- let lift = 0;
295
- while (this.at("caret")) { this.next(); lift++; }
296
- if (!this.at("ident")) this.fail("expected a projection name");
297
- const nameTok = this.next();
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
- // bare/dotted projection: key defaults to the last navigation segment.
313
- const { expr, name } = this.parseNavFrom(nameTok);
314
- return { kind: "field", name, expr, lift };
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")) receiver = this.parseNavFrom(this.next()).expr;
397
- else return null;
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 { sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow }, distinct: body.distinct };
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
- let levels = 0;
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` (field is a bare row column). */
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 };