@omgbase/oqx 0.15.0 → 0.20.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 (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +308 -70
  3. package/dist/ast.d.ts +97 -27
  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 +43 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +58 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/context.d.ts +30 -6
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +11 -10
  14. package/dist/context.js.map +1 -1
  15. package/dist/engine.d.ts +13 -3
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engine.js +281 -100
  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 +36 -13
  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 +3 -2
  32. package/dist/optimize/analysis.d.ts.map +1 -1
  33. package/dist/optimize/analysis.js +19 -18
  34. package/dist/optimize/analysis.js.map +1 -1
  35. package/dist/optimize/ir.d.ts +2 -2
  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 +782 -335
  41. package/dist/parser.js.map +1 -1
  42. package/dist/plan.d.ts.map +1 -1
  43. package/dist/plan.js +4 -2
  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 +333 -0
  52. package/dist/resolve.js.map +1 -0
  53. package/dist/version.d.ts +5 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +5 -0
  56. package/dist/version.js.map +1 -0
  57. package/dist/walk.d.ts +56 -0
  58. package/dist/walk.d.ts.map +1 -0
  59. package/dist/walk.js +157 -0
  60. package/dist/walk.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/ast.ts +126 -40
  63. package/src/build.ts +73 -0
  64. package/src/context.ts +36 -12
  65. package/src/engine.ts +285 -110
  66. package/src/errors.ts +3 -2
  67. package/src/index.ts +40 -14
  68. package/src/lexer.ts +102 -51
  69. package/src/optimize/analysis.ts +17 -15
  70. package/src/optimize/ir.ts +4 -4
  71. package/src/parser.ts +721 -289
  72. package/src/plan.ts +3 -2
  73. package/src/print.ts +276 -0
  74. package/src/resolve.ts +309 -0
  75. package/src/version.ts +4 -0
  76. package/src/walk.ts +201 -0
package/src/engine.ts CHANGED
@@ -29,11 +29,15 @@ import {
29
29
  relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange,
30
30
  } from "./semantics.ts";
31
31
  import type { BlockPlan, Correlation, Rule, RowIndex } from "./optimize/index.ts";
32
+ import { printTemplate } from "./print.ts";
32
33
  import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.ts";
33
34
 
34
- /** The shaped result of a top-level query, discriminated by consumer. */
35
+ /** The shaped result of a top-level query, discriminated by consumer. A
36
+ * `collect` over a **single** receiver (one object, SEMANTICS §3) is one record
37
+ * or absent rather than a list: `single` is true and `rows` holds at most that
38
+ * one row (`unwrap` yields `rows[0] ?? null`). */
35
39
  export type OqxResult =
36
- | { consumer: "collect"; rows: unknown[] }
40
+ | { consumer: "collect"; rows: unknown[]; single: boolean }
37
41
  | { consumer: "exists"; exists: boolean }
38
42
  | { consumer: "none"; none: boolean }
39
43
  | { consumer: "count"; count: number }
@@ -46,7 +50,9 @@ export interface Engine {
46
50
  }
47
51
 
48
52
  // One query scope: the row under evaluation plus the chain of enclosing scopes
49
- // that `^` walks. The root scope (parent === null) has no row; its names are the
53
+ // that `^` walks. The root scope (parent === null) is a row too: its row is the
54
+ // host's root object (`DataContext.rootObject`, since 0.18 — what `$it` is
55
+ // there and `^$it` from a top-level row), while its bare names are the
50
56
  // context's named roots. `lifts` holds values bound INTO this scope by `^name:`
51
57
  // items in nested blocks; `meta` holds the scope's intrinsics: recursion
52
58
  // metadata for a follow occurrence, and `$key` for an entry scope (a row that
@@ -81,7 +87,7 @@ interface RunState {
81
87
  interface CollectionState {
82
88
  value: unknown;
83
89
  seen: number;
84
- rows: unknown[] | null;
90
+ rows: Rows | null;
85
91
  indexes: Map<string, RowIndex | null>;
86
92
  contextIndexes: Map<string, RowIndex | null>;
87
93
  }
@@ -116,6 +122,20 @@ const HARD_DEPTH_CAP = 8;
116
122
  // `Query` and `Subquery` carry this shape.
117
123
  type Projection = Pick<Subquery, "select" | "values">;
118
124
 
125
+ // The `$key: expr` item of a projection, if any — the identity the rows it
126
+ // yields carry (SEMANTICS §16, since 0.20).
127
+ function keyItem(proj: Projection): Extract<SelectItem, { kind: "field" }> | null {
128
+ for (const it of proj.select) if (it.kind === "field" && it.lift === 0 && it.name === KEY) return it;
129
+ return null;
130
+ }
131
+
132
+ // A row with the identity the walk knows it by.
133
+ interface Keyed { row: unknown; key: unknown; }
134
+
135
+ // The rows of a value in row position, and whether the value was a single row
136
+ // (one object) rather than a collection — which decides a block's result shape.
137
+ interface Rows { rows: unknown[]; single: boolean; }
138
+
119
139
  // An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
120
140
  interface Bound { offset: number; limit: number | null; }
121
141
  const UNBOUNDED: Bound = { offset: 0, limit: null };
@@ -140,8 +160,12 @@ export class InMemoryEngine implements Engine {
140
160
  memo: new Map(),
141
161
  };
142
162
  if (this.trace) run.trace = this.trace;
143
- const root: Scope = { row: null, parent: null, bindings, run };
144
- const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
163
+ const root: Scope = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
164
+ const src = this.coerceRows(this.evalRaw(query.source, root), query.source);
165
+ const rows = this.reproject(src.rows, query.from, root);
166
+ // One object as the source yields one record (SEMANTICS §3) — unless a
167
+ // `from` re-projection or a walk fans it out.
168
+ const single = src.single && query.from.length === 0 && !query.follow;
145
169
 
146
170
  const bound = this.boundOf(query, root);
147
171
  if (query.follow) return this.runFollow(query, rows, root, bound);
@@ -176,21 +200,26 @@ export class InMemoryEngine implements Engine {
176
200
  if (kept.length >= cap) break;
177
201
  }
178
202
  }
203
+ this.applyKey(kept, query);
179
204
  this.sortScopes(kept, query.orderBy);
180
205
  if (query.distinct) kept = this.dedupByProjection(kept, query);
181
206
  kept = sliceBound(kept, bound);
182
- return this.shape(query.consumer, kept, query);
207
+ return this.shape(query.consumer, kept, query, single);
183
208
  }
184
209
 
185
210
  // Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
186
211
  // is read in a row-less scope INSIDE it: a bare name is absent (there is no
187
212
  // current item yet), `^name` is the enclosing row — exactly as in the block's
188
- // body — and literals/bindings are themselves. For a top-level query `scope`
189
- // is the root. Each must be a non-negative integer.
213
+ // body — and literals/bindings are themselves. For a top-level query the
214
+ // scope is the root scope WITHOUT its row (SEMANTICS §18): bare names are
215
+ // still the named roots, but `$it` — reached through a bracket inside the
216
+ // bound — is absent, not the root object. Each must be a non-negative integer.
190
217
  private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
191
218
  if (!b.limit && !b.offset) return UNBOUNDED;
192
- const scope: Scope = enclosing.parent === null ? enclosing : rowless(enclosing);
193
- const read = (e: Expr | undefined, word: string): number | null => {
219
+ const scope: Scope = enclosing.parent === null
220
+ ? { row: undefined, parent: null, bindings: enclosing.bindings, run: enclosing.run }
221
+ : rowless(enclosing);
222
+ const read = (e: Expr | null, word: string): number | null => {
194
223
  if (!e) return null;
195
224
  const v = this.evalExpr(e, scope);
196
225
  if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
@@ -207,8 +236,56 @@ export class InMemoryEngine implements Engine {
207
236
  return this.evalWhere(where, this.enter(row, parent));
208
237
  }
209
238
 
210
- private rowsOf(v: unknown): unknown[] {
211
- return Array.from(this.ctx.toRows(v));
239
+ // The rows of a value in row position (SEMANTICS §3): absent → none; an array
240
+ // (or a host iterable) → its elements, a collection; an object → one row, and
241
+ // the value is SINGLE — a block over it yields one result rather than a list;
242
+ // a scalar (a string, a number, a boolean) or a range → an eval error. A
243
+ // context says which of its objects are single (`isSingle`); without it, any
244
+ // non-iterable object is.
245
+ private coerceRows(v: unknown, what: Expr | null): Rows {
246
+ if (v == null) return { rows: [], single: false };
247
+ if (Array.isArray(v)) return { rows: v, single: false };
248
+ if (typeof v !== "object" || isRange(v)) {
249
+ const kind = isRange(v) ? "a range" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : `a ${typeof v}`;
250
+ const shown = isRange(v) ? "" : ` (${JSON.stringify(v)})`;
251
+ const subject = what ? `\`${describe(what)}\` is` : "the receiver is";
252
+ throw new OqxError(`${subject} ${kind}${shown}, not a collection or an object — rows come from a collection (a list) or one object (a record)`, "eval");
253
+ }
254
+ const rows = Array.from(this.ctx.toRows(v));
255
+ const single = this.ctx.isSingle ? this.ctx.isSingle(v) : !(Symbol.iterator in v);
256
+ return { rows, single };
257
+ }
258
+
259
+ private rowsOf(v: unknown, what: Expr | null = null): unknown[] {
260
+ return this.coerceRows(v, what).rows;
261
+ }
262
+
263
+ // The host's identity of a row (SEMANTICS §16): an entry is known by its key;
264
+ // any other row by the context's `identityOf`, else by its structural value.
265
+ private hostIdentity(row: unknown): unknown {
266
+ if (isEntry(row)) return row.key;
267
+ if (this.ctx.identityOf) return this.ctx.identityOf(row);
268
+ if (this.ctx.identity) return this.ctx.identity(row);
269
+ return row;
270
+ }
271
+
272
+ // The identity a scope's row is known by — `$key`: the identity the walk or a
273
+ // `$key:` projection gave it (`meta`), else the host's.
274
+ private scopeIdentity(scope: Scope): unknown {
275
+ if (scope.meta && Object.hasOwn(scope.meta, KEY)) return scope.meta[KEY];
276
+ if (scope.parent === null && scope.row === undefined) return undefined;
277
+ return this.hostIdentity(scope.row);
278
+ }
279
+
280
+ // A projection's `$key: expr` item gives the rows it yields that identity:
281
+ // evaluate it on each kept scope and record it as the scope's `$key`.
282
+ private applyKey(scopes: Scope[], proj: Projection): void {
283
+ const item = keyItem(proj);
284
+ if (!item) return;
285
+ for (const s of scopes) {
286
+ const key = this.evalExpr(item.expr, s);
287
+ s.meta = { ...(s.meta ?? {}), [KEY]: key };
288
+ }
212
289
  }
213
290
 
214
291
  private child(row: unknown, parent: Scope): Scope {
@@ -236,13 +313,20 @@ export class InMemoryEngine implements Engine {
236
313
  private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
237
314
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
238
315
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
239
- const occ = this.followWalk(seeds, query.follow!, root);
316
+ const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
240
317
  let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
241
318
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
242
319
  this.sortScopes(scopes, query.orderBy);
243
320
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
244
321
  scopes = sliceBound(scopes, bound);
245
- return this.shape(query.consumer, scopes, query);
322
+ return this.shape(query.consumer, scopes, query, false);
323
+ }
324
+
325
+ // The seeds of a walk with their identities: the body's `$key: expr` item,
326
+ // read in each seed's scope, else the host's identity (SEMANTICS §20).
327
+ private keyed(rows: unknown[], proj: Projection, parent: Scope): Keyed[] {
328
+ const item = keyItem(proj);
329
+ return rows.map((row) => ({ row, key: item ? this.evalExpr(item.expr, this.enter(row, parent)) : this.hostIdentity(row) }));
246
330
  }
247
331
 
248
332
  // Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
@@ -255,7 +339,7 @@ export class InMemoryEngine implements Engine {
255
339
  const out: Scope[] = [];
256
340
  for (const s of scopes) {
257
341
  const key = proj.select.length === 0
258
- ? `i:${canonicalKey(this.ctx.identity(s.row))}`
342
+ ? `i:${canonicalKey(this.scopeIdentity(s))}`
259
343
  : `p:${canonicalKey(this.projectRow(proj, s))}`;
260
344
  if (seen.has(key)) continue;
261
345
  seen.add(key);
@@ -265,79 +349,90 @@ export class InMemoryEngine implements Engine {
265
349
  }
266
350
 
267
351
  // A bounded, per-path recursive walk. Each occurrence carries recursion
268
- // metadata: `$depth` (seed = 1), a categorical `$stop`, and a deterministic
269
- // `$ordinal`. Semantics:
352
+ // metadata: `$depth` (seed = 1), a categorical `$stop`, a deterministic
353
+ // `$ordinal`, and `$key`, the identity it is known by. Semantics (SEMANTICS §20):
270
354
  // • per-path — a node reached by N distinct paths yields N occurrences
271
355
  // (unless `distinct`, which keeps the minimal (depth, path) per identity);
272
- // • cycles are safe — revisiting a key already on the current path admits ONE
273
- // occurrence with `$stop == "cycle"` and does not expand it (no runaway);
356
+ // • cycles are safe — revisiting an identity already on the current path
357
+ // admits ONE occurrence with `$stop == "cycle"` and does not expand it;
274
358
  // • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
275
359
  // cycle > frontier > depth > leaf > interior; only `interior` rows expand;
276
360
  // • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
277
361
  // "there is unfollowed graph beyond me" signal;
278
- // • identity for cycle detection + `distinct` is `by <expr>` when given, else
279
- // `ctx.identity(row)` (an entry's identity is its value's); identities are
280
- // compared structurally via `canonicalKey`, and `$ordinal` paths compare
281
- // component-wise (`comparePath`), so `10` follows `9`;
362
+ // • identity is `$key` (since 0.20): a seed's is the body's `$key:` item or
363
+ // the host's identity; a successor's is its destination block's `$key:`
364
+ // item, else the host's identity of the yielded row. Identities compare
365
+ // structurally (`canonicalKey`); `$ordinal` paths compare component-wise
366
+ // (`comparePath`), so `10` follows `9`;
282
367
  // • a row's successors are the destinations' rows in source order — a plain
283
368
  // relation read in the row's scope, a destination block evaluated as a
284
369
  // select-position directive in that scope (so `^` inside it is the frontier
285
- // row and its `^people` receiver resolves one scope further out) — filtered
286
- // by the follow `where`, then unioned by identity within the step (a
287
- // recurring identity is kept once, at its first position);
288
- // • the follow `where` reads the candidate's scope hung off the FRONTIER
289
- // row's scope: a bare name is the candidate's, `^name` the row being
290
- // expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
291
- // the occurrence's own scope with `^` the enclosing scope, as the body does.
292
- private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
293
- const cap = follow.depth ?? HARD_DEPTH_CAP;
294
- const scopeFor = (row: unknown): Scope => this.enter(row, parent);
295
- const identityOf = (row: unknown): unknown =>
296
- follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
297
- const succOf = (row: unknown): unknown[] => {
298
- const rowScope = scopeFor(row);
299
- const raw: unknown[] = [];
300
- for (const dest of follow.destinations) {
301
- const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
302
- raw.push(...this.rowsOf(v));
303
- }
304
- const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where!, this.enter(x, rowScope)))) : raw;
370
+ // row and its `^people` receiver resolves one scope further out), shaped by
371
+ // the block's items other than `$key` — then unioned by identity within
372
+ // the step (a recurring identity is kept once, at its first position, and
373
+ // the destination that reached it first governs the occurrence);
374
+ // • a destination block's `depth n` (0.20) makes it decline to expand a
375
+ // frontier row whose `$depth >= n`; a row with no successors some
376
+ // destination declined is `depth`, with none declined `leaf`; the hard
377
+ // cap 8 is every destination's ceiling;
378
+ // • a destination block's `frontier P` marks the occurrences IT reached for
379
+ // which `P` holds (seeds are reached by none); `P` reads the occurrence's
380
+ // own scope with `^` the walk's enclosing scope, as the body does;
381
+ // • the body's own `frontier P` (`seedFrontier`, 0.20) marks the SEEDS for
382
+ // which `P` holds, read in the seed's scope exactly like the body's `where`.
383
+ private followWalk(seeds: Keyed[], follow: Follow, seedFrontier: Expr | null, parent: Scope): Occurrence[] {
384
+ const frontierScope = (k: Keyed): Scope => this.enter(k.row, parent, { meta: { [KEY]: k.key } });
385
+ const succOf = (k: Keyed, depth: number): { succ: (Keyed & { via: number })[]; declined: boolean } => {
386
+ const rowScope = frontierScope(k);
387
+ const raw: (Keyed & { via: number })[] = [];
388
+ let declined = false;
389
+ follow.destinations.forEach((dest, via) => {
390
+ if (dest.kind === "op") {
391
+ if (dest.sub.depth !== null && depth >= dest.sub.depth) { declined = true; return; }
392
+ for (const x of this.destinationRows(dest, rowScope)) raw.push({ ...x, via });
393
+ } else {
394
+ for (const row of this.rowsOf(this.evalRaw(dest, rowScope), dest)) raw.push({ row, key: this.hostIdentity(row), via });
395
+ }
396
+ });
305
397
  const seen = new Set<string>();
306
- const out: unknown[] = [];
307
- for (const x of kept) {
308
- const key = canonicalKey(identityOf(x));
398
+ const succ: (Keyed & { via: number })[] = [];
399
+ for (const x of raw) {
400
+ const key = canonicalKey(x.key);
309
401
  if (seen.has(key)) continue;
310
402
  seen.add(key);
311
- out.push(x);
403
+ succ.push(x);
312
404
  }
313
- return out;
405
+ return { succ, declined };
406
+ };
407
+ const frontierHit = (k: Keyed, via: number | null): boolean => {
408
+ if (via === null) return seedFrontier !== null && truthy(this.evalExpr(seedFrontier, frontierScope(k)));
409
+ const dest = follow.destinations[via]!;
410
+ if (dest.kind !== "op" || !dest.sub.frontier) return false;
411
+ return truthy(this.evalExpr(dest.sub.frontier, frontierScope(k)));
314
412
  };
315
- const frontierHit = (row: unknown): boolean =>
316
- follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
317
413
 
318
- interface Walked { row: unknown; depth: number; path: unknown[]; key: string; stop: string; }
414
+ interface Walked { row: unknown; depth: number; path: unknown[]; id: unknown; key: string; stop: string; }
319
415
  const walked: Walked[] = [];
320
416
 
321
- const visit = (row: unknown, depth: number, ancestors: string[], ancestorIds: unknown[]): void => {
322
- const id = identityOf(row);
323
- const key = canonicalKey(id);
324
- const path = [...ancestorIds, id];
417
+ const visit = (k: Keyed, via: number | null, depth: number, ancestors: string[], ancestorIds: unknown[]): void => {
418
+ const key = canonicalKey(k.key);
419
+ const path = [...ancestorIds, k.key];
325
420
  let stop: string;
326
421
  if (ancestors.includes(key)) stop = "cycle";
327
- else if (frontierHit(row)) stop = "frontier";
328
- else if (depth >= cap) stop = "depth";
422
+ else if (frontierHit(k, via)) stop = "frontier";
423
+ else if (depth >= HARD_DEPTH_CAP) stop = "depth";
329
424
  else {
330
- const succ = succOf(row);
331
- if (succ.length === 0) stop = "leaf";
425
+ const { succ, declined } = succOf(k, depth);
426
+ if (succ.length === 0) stop = declined ? "depth" : "leaf";
332
427
  else {
333
- walked.push({ row, depth, path, key, stop: "interior" });
334
- for (const s of succ) visit(s, depth + 1, [...ancestors, key], path);
428
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop: "interior" });
429
+ for (const x of succ) visit(x, x.via, depth + 1, [...ancestors, key], path);
335
430
  return;
336
431
  }
337
432
  }
338
- walked.push({ row, depth, path, key, stop });
433
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop });
339
434
  };
340
- for (const r of seedRows) visit(r, 1, [], []);
435
+ for (const k of seeds) visit(k, null, 1, [], []);
341
436
 
342
437
  let rows = walked;
343
438
  if (follow.distinct) {
@@ -354,6 +449,7 @@ export class InMemoryEngine implements Engine {
354
449
  return rows.map((w, i) => ({
355
450
  row: w.row,
356
451
  meta: {
452
+ [KEY]: w.id,
357
453
  $depth: w.depth,
358
454
  $stop: w.stop,
359
455
  $leaf: w.stop === "leaf",
@@ -363,14 +459,32 @@ export class InMemoryEngine implements Engine {
363
459
  }));
364
460
  }
365
461
 
462
+ // A destination block's successors for one frontier row, each with its
463
+ // identity: the block's rows (its `where`, order, `distinct`, bound and
464
+ // consumer applied — `first`/`single` yield at most one), shaped by its items
465
+ // other than `$key` (the raw rows when none remain), keyed by its `$key:`
466
+ // item read in the row's scope, else by the host's identity of the row yielded.
467
+ private destinationRows(dest: OpNode, rowScope: Scope): Keyed[] {
468
+ const { scopes } = this.evalCollectScopes(dest, rowScope, this.planOf(dest, rowScope), true);
469
+ const key = keyItem(dest.sub);
470
+ const shape: Projection = key ? { select: dest.sub.select.filter((it) => it !== key), values: dest.sub.values } : dest.sub;
471
+ return scopes.map((s) => {
472
+ const row = this.projectRow(shape, s);
473
+ const id = key ? this.evalExpr(key.expr, s) : shape.select.length === 0 ? this.scopeIdentity(s) : this.hostIdentity(row);
474
+ return { row, key: id };
475
+ });
476
+ }
477
+
366
478
  // ---- consumer shaping -----------------------------------------------------
367
479
 
368
- private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
480
+ // `single` — the receiver was one object: a `collect` yields its one record
481
+ // (or nothing) rather than a one-element list (SEMANTICS §3, §15).
482
+ private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection, single: boolean): OqxResult {
369
483
  switch (consumer) {
370
484
  case "exists": return { consumer, exists: scopes.length > 0 };
371
485
  case "none": return { consumer, none: scopes.length === 0 };
372
486
  case "count": return { consumer, count: scopes.length };
373
- case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
487
+ case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)), single };
374
488
  case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
375
489
  case "single":
376
490
  if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
@@ -455,6 +569,7 @@ export class InMemoryEngine implements Engine {
455
569
  // The rows a consumer op reduces: matched → ordered → distinct → bounded.
456
570
  private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
457
571
  let scopes = this.matchRows(op, scope, acc);
572
+ this.applyKey(scopes, op.sub);
458
573
  this.sortScopes(scopes, op.sub.orderBy);
459
574
  if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
460
575
  return sliceBound(scopes, bound);
@@ -516,13 +631,13 @@ export class InMemoryEngine implements Engine {
516
631
  if (!state) {
517
632
  const value = this.evalRaw(op.receiver, scope);
518
633
  if (plan.receiverStable) run.stableCollections.set(op, (state = newCollection(value)));
519
- else if (plan.correlated.length === 0) return this.scan(this.rowsOf(value), op, scope);
634
+ else if (plan.correlated.length === 0) return this.scan(this.coerceRows(value, op.receiver), op, scope);
520
635
  else if (value !== null && typeof value === "object") {
521
636
  state = run.collections.get(value);
522
637
  if (!state) run.collections.set(value, (state = newCollection(value)));
523
638
  } else {
524
639
  run.trace?.({ kind: "fallback", reason: "not-a-collection" });
525
- return this.scan(this.rowsOf(value), op, scope);
640
+ return this.scan(this.coerceRows(value, op.receiver), op, scope);
526
641
  }
527
642
  }
528
643
  state.seen++;
@@ -530,25 +645,26 @@ export class InMemoryEngine implements Engine {
530
645
  const direct = this.lookupRows(plan, state, scope);
531
646
  if (direct) return direct;
532
647
  if (state.seen >= 2 || plan.receiverStable) {
533
- const probed = this.probe(plan, state, scope);
534
- if (probed) return { rows: probed, where: plan.residual };
648
+ const probed = this.probe(plan, state, scope, op);
649
+ if (probed) return { rows: probed, where: plan.residual, single: false };
535
650
  } else run.trace?.({ kind: "fallback", reason: "unstable" });
536
651
  }
537
- return this.scan(this.materialize(state), op, scope);
652
+ return this.scan(this.materialize(state, op), op, scope);
538
653
  }
539
654
 
540
655
  // The plain scan: the rows, re-projected by `from`, with the whole `where`.
541
- private scan(rows: unknown[], op: OpNode, scope: Scope): Access {
542
- return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
656
+ // A `from` re-projection fans a single receiver out: the block is plural.
657
+ private scan(src: Rows, op: OpNode, scope: Scope): Access {
658
+ return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 };
543
659
  }
544
660
 
545
661
  private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
546
- for (const proj of from) rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
662
+ for (const proj of from) rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope)), proj));
547
663
  return rows;
548
664
  }
549
665
 
550
- private materialize(state: CollectionState): unknown[] {
551
- return (state.rows ??= this.rowsOf(state.value));
666
+ private materialize(state: CollectionState, op: OpNode): Rows {
667
+ return (state.rows ??= this.coerceRows(state.value, op.receiver));
552
668
  }
553
669
 
554
670
  // A probe answered by a context index that yields rows directly, before the
@@ -562,7 +678,7 @@ export class InMemoryEngine implements Engine {
562
678
  if (!outer) return null;
563
679
  const rows = Array.from(index.lookupRows(outer.value));
564
680
  scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
565
- return { rows, where: residualWithout(plan, c) };
681
+ return { rows, where: residualWithout(plan, c), single: false };
566
682
  }
567
683
  return null;
568
684
  }
@@ -570,12 +686,12 @@ export class InMemoryEngine implements Engine {
570
686
  // The engine's probe: one bucket per correlation, intersected smallest-first;
571
687
  // the candidates come back in receiver order. `null` (an index that could
572
688
  // not be built, an outer value whose evaluation threw) leaves the scan in charge.
573
- private probe(plan: BlockPlan, state: CollectionState, scope: Scope): unknown[] | null {
689
+ private probe(plan: BlockPlan, state: CollectionState, scope: Scope, op: OpNode): unknown[] | null {
574
690
  const run = scope.run;
575
- const rows = this.materialize(state);
691
+ const rows = this.materialize(state, op).rows;
576
692
  const buckets: (readonly number[])[] = [];
577
693
  for (const c of plan.correlated) {
578
- const index = this.indexFor(state, c, scope);
694
+ const index = this.indexFor(state, c, scope, op);
579
695
  if (!index) { run.trace?.({ kind: "fallback", reason: "index" }); return null; }
580
696
  const outer = this.outerValue(c, scope);
581
697
  if (!outer) return null;
@@ -608,11 +724,11 @@ export class InMemoryEngine implements Engine {
608
724
  // here by evaluating the local expression in each row's scope — the same read
609
725
  // the scan performs, so the keys are exactly what `==` would compare. A throw
610
726
  // while building (a context that rejects a read) leaves the path un-indexed.
611
- private indexFor(state: CollectionState, c: Correlation, scope: Scope): RowIndex | null {
727
+ private indexFor(state: CollectionState, c: Correlation, scope: Scope, op: OpNode): RowIndex | null {
612
728
  const key = pathKey(c.path);
613
729
  const hit = state.indexes.get(key);
614
730
  if (hit !== undefined) return hit;
615
- const rows = this.materialize(state);
731
+ const rows = this.materialize(state, op).rows;
616
732
  let index = this.contextIndex(state, c);
617
733
  let source: "context" | "engine" = "context";
618
734
  if (!index) {
@@ -636,27 +752,45 @@ export class InMemoryEngine implements Engine {
636
752
  }
637
753
 
638
754
  private evalCollectValuePlanned(op: OpNode, plan: BlockPlan, scope: Scope): unknown {
755
+ const { scopes, single } = this.evalCollectScopes(op, scope, plan);
756
+ switch (op.op) {
757
+ // Over a single receiver (one object) a `collect` is the record or absent,
758
+ // not a one-element list (SEMANTICS §3).
759
+ case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
760
+ default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : null;
761
+ }
762
+ }
763
+
764
+ // The rows a select-position directive yields, as scopes, reduced by its
765
+ // consumer (`first`/`single` keep at most one; `single` raises on more), and
766
+ // whether its receiver was a single object. `destination` marks a follow
767
+ // destination block: its `frontier` is the destination's (read by the outer
768
+ // walk), never a seed frontier for a `follow` of its own.
769
+ private evalCollectScopes(op: OpNode, scope: Scope, plan: BlockPlan = this.planOf(op, scope), destination = false): { scopes: Scope[]; single: boolean } {
639
770
  const sub = op.sub;
640
771
  const bound = this.boundOf(sub, scope);
641
772
  let scopes: Scope[];
773
+ let single = false;
642
774
  if (sub.follow) {
643
775
  // The seeds are the matched rows themselves (`followWalk` enters them); a
644
776
  // follow block is never correlated, so `where` here is the whole predicate.
645
777
  const { rows, where } = this.access(op, plan, scope);
646
778
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
647
- scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
779
+ scopes = this.followWalk(this.keyed(seeds, sub, scope), sub.follow, destination ? null : sub.frontier, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
648
780
  this.sortScopes(scopes, sub.orderBy);
649
781
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
650
782
  scopes = sliceBound(scopes, bound);
651
783
  } else {
652
- scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
784
+ const acc = this.access(op, plan, scope);
785
+ single = acc.single;
786
+ scopes = this.opRows(op, scope, bound, acc);
653
787
  }
654
788
  switch (op.op) {
655
- case "collect": return scopes.map((s) => this.projectRow(sub, s));
656
- case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
789
+ case "collect": return { scopes, single };
790
+ case "first": return { scopes: scopes.slice(0, 1), single };
657
791
  case "single":
658
792
  if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
659
- return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
793
+ return { scopes, single };
660
794
  default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
661
795
  }
662
796
  }
@@ -719,20 +853,30 @@ export class InMemoryEngine implements Engine {
719
853
  const r = this.evalExpr(e.recv, scope);
720
854
  return r == null ? undefined : this.ctx.get(r, e.name);
721
855
  }
722
- case "index": {
723
- const r = this.evalExpr(e.recv, scope);
724
- const i = this.evalExpr(e.index, scope);
725
- return r == null ? undefined : this.ctx.get(r, String(i));
726
- }
727
856
  case "call": return this.evalCall(e, scope);
728
857
  case "unary": {
729
858
  const v = this.evalExpr(e.expr, scope);
730
859
  if (e.op === "!") return !truthy(v);
731
860
  return v == null ? undefined : -(v as number);
732
861
  }
862
+ // `x!` — the value, or an eval error naming the expression (and the row's
863
+ // identity when it has a scalar one). Never a filter, never a coercion.
864
+ case "required": {
865
+ const v = this.evalExpr(e.expr, scope);
866
+ if (v == null) throw new OqxError(`\`${describe(e.expr)}!\` is absent${this.rowIdentity(scope)}`, "eval");
867
+ return v;
868
+ }
869
+ // A value-position directive (`jobs first { … }.pay`, the desugared `x[…]`).
870
+ case "op": return this.evalCollectValue(e, scope);
733
871
  case "binary": {
734
872
  const l = this.evalExpr(e.left, scope);
735
873
  const r = this.evalExpr(e.right, scope);
874
+ if (e.op === "is" || e.op === "is not") {
875
+ // Identity (SEMANTICS §5b): the host's identity of each side,
876
+ // compared structurally — the key `distinct` and `follow` use.
877
+ const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
878
+ return e.op === "is" ? same : !same;
879
+ }
736
880
  return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
737
881
  }
738
882
  case "logical": {
@@ -745,16 +889,34 @@ export class InMemoryEngine implements Engine {
745
889
  e.lo == null ? null : this.evalExpr(e.lo, scope),
746
890
  e.hi == null ? null : this.evalExpr(e.hi, scope),
747
891
  e.exclusiveEnd);
892
+ // A tuple (SEMANTICS §3b): the elements in order, concatenated with ONE
893
+ // level of flattening — a collection contributes its items, anything else
894
+ // itself; an absent (`undefined`) element contributes nothing, a `null`
895
+ // one `null`; duplicates are kept.
896
+ case "tuple": {
897
+ const out: unknown[] = [];
898
+ for (const item of e.items) {
899
+ const v = this.evalExpr(item, scope);
900
+ if (v === undefined) continue;
901
+ if (Array.isArray(v)) out.push(...v);
902
+ else if (v !== null && typeof v === "object" && !isEntry(v) && !isRange(v) && Symbol.iterator in v) out.push(...(v as Iterable<unknown>));
903
+ else out.push(v);
904
+ }
905
+ return out;
906
+ }
748
907
  }
749
908
  }
750
909
 
751
910
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
752
911
  // in order: `$it` (the scope's row itself — the current item, whatever its
753
- // type, so scalar collections are queryable; absent at the root, which has no
754
- // row; `$value`, its pre-0.15 spelling, is an ordinary property name now); `$key` (the property key) when it is an entry scope — see `enter`;
755
- // the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
756
- // values lifted into it by `^name:` items; then either the row's own property
757
- // or, for the root scope (no row), the context's named roots.
912
+ // type, so scalar collections are queryable; at the root the host's root
913
+ // object, since 0.18; `$value`, its pre-0.15 spelling, is an ordinary
914
+ // property name now); `$key` (the property key) when it is an entry scope —
915
+ // see `enter`; the recursion intrinsics (`$depth`, …) when it is a follow
916
+ // occurrence; values lifted into it by `^name:` items; then either the row's
917
+ // own property or, for the root scope, the context's named roots (`root(name)`
918
+ // — never a property of the root object, so a host with lazy roots is
919
+ // unaffected by what its root object holds).
758
920
  //
759
921
  // The two metadata steps apply only where the scope CARRIES that metadata
760
922
  // (SEMANTICS §2, since 0.13). Anywhere else — `$key` on an ordinary row,
@@ -770,13 +932,24 @@ export class InMemoryEngine implements Engine {
770
932
  // always spelled explicitly as `^name`. Present-but-falsy values (null, false,
771
933
  // 0, "") need no special case — there is no "absent, so look outward" rule.
772
934
  private resolveIn(name: string, scope: Scope): unknown {
773
- if (name === "$it") return scope.parent === null ? undefined : scope.row;
774
- if ((name === KEY || RECUR.has(name)) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
935
+ if (name === "$it") return scope.row;
936
+ // `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
937
+ if (name === KEY) return this.scopeIdentity(scope);
938
+ if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
775
939
  if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
776
940
  if (scope.parent === null) return this.ctx.root(name);
777
941
  return this.ctx.get(scope.row, name);
778
942
  }
779
943
 
944
+ // ` on <identity>` for a required-value error when the scope's row has a
945
+ // scalar identity (`$key`: the host's, an entry's key, a `$key:` projection);
946
+ // empty otherwise, and at the root (the root object is not a row of the data).
947
+ private rowIdentity(scope: Scope): string {
948
+ if (scope.parent === null || scope.row == null) return "";
949
+ const id = this.scopeIdentity(scope);
950
+ return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
951
+ }
952
+
780
953
  private evalCall(e: Extract<Expr, { kind: "call" }>, scope: Scope): unknown {
781
954
  const args = e.args.map((a) => this.evalExpr(a, scope));
782
955
  if (e.recv === null) {
@@ -793,8 +966,9 @@ export class InMemoryEngine implements Engine {
793
966
 
794
967
  interface Occurrence { row: unknown; meta: Record<string, unknown>; }
795
968
 
796
- // A block's accessed rows and the predicate still to evaluate over each.
797
- interface Access { rows: unknown[]; where: Where | null; }
969
+ // A block's accessed rows, the predicate still to evaluate over each, and
970
+ // whether the receiver was a single object (SEMANTICS §3).
971
+ interface Access { rows: unknown[]; where: Where | null; single: boolean; }
798
972
 
799
973
  // ---- free helpers -----------------------------------------------------------
800
974
 
@@ -873,6 +1047,13 @@ function boundedCount(n: number, b: Bound): number {
873
1047
  return b.limit == null ? rest : Math.min(rest, b.limit);
874
1048
  }
875
1049
 
1050
+ // The canonical text of an expression for an error message; a binding shows as
1051
+ // its `${n}` marker (the printer refuses to turn a bound value into source).
1052
+ function describe(e: Expr): string {
1053
+ const t = printTemplate(e);
1054
+ return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
1055
+ }
1056
+
876
1057
  function describeReceiver(e: Expr): string {
877
1058
  if (e.kind === "ident") return e.name;
878
1059
  if (e.kind === "member") return `${describeReceiver(e.recv)}.${e.name}`;
@@ -890,7 +1071,7 @@ function partitionRecur(w: Where): { seed: Where | null; post: Where | null } {
890
1071
  const seed: Where[] = [];
891
1072
  const post: Where[] = [];
892
1073
  for (const p of parts) (whereHasRecur(p) ? post : seed).push(p);
893
- const rebuild = (ps: Where[]): Where | null => (ps.length === 0 ? null : ps.length === 1 ? ps[0]! : { kind: "and", parts: ps });
1074
+ const rebuild = (ps: Where[]): Where | null => (ps.length === 0 ? null : ps.length === 1 ? ps[0]! : { kind: "and", span: [0, 0], parts: ps });
894
1075
  return { seed: rebuild(seed), post: rebuild(post) };
895
1076
  }
896
1077
 
@@ -907,17 +1088,11 @@ function exprHasRecur(e: Expr): boolean {
907
1088
  switch (e.kind) {
908
1089
  case "ident": return RECUR.has(e.name);
909
1090
  case "member": return exprHasRecur(e.recv);
910
- case "index": return exprHasRecur(e.recv) || exprHasRecur(e.index);
911
1091
  case "call": return (e.recv ? exprHasRecur(e.recv) : false) || e.args.some(exprHasRecur);
912
- case "unary": return exprHasRecur(e.expr);
1092
+ case "unary": case "required": return exprHasRecur(e.expr);
913
1093
  case "binary": case "logical": case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
914
1094
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1095
+ case "tuple": return e.items.some(exprHasRecur);
915
1096
  default: return false;
916
1097
  }
917
1098
  }
918
-
919
- /** Convenience: run a query with plain-object roots (the default context). */
920
- export function runQuery(query: Query, bindings: readonly unknown[], roots: unknown): OqxResult {
921
- const ctx = new DefaultContext((roots as Record<string, unknown>) ?? {});
922
- return new InMemoryEngine(ctx).run(query, bindings);
923
- }