@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.
- package/CHANGELOG.md +60 -0
- package/README.md +308 -70
- package/dist/ast.d.ts +97 -27
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +21 -6
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +43 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +58 -0
- package/dist/build.js.map +1 -0
- package/dist/context.d.ts +30 -6
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +11 -10
- package/dist/context.js.map +1 -1
- package/dist/engine.d.ts +13 -3
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +281 -100
- package/dist/engine.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +19 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -13
- package/dist/index.js.map +1 -1
- package/dist/lexer.d.ts +15 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +104 -51
- package/dist/lexer.js.map +1 -1
- package/dist/optimize/analysis.d.ts +3 -2
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +19 -18
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +2 -2
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +3 -3
- package/dist/optimize/ir.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +782 -335
- package/dist/parser.js.map +1 -1
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +4 -2
- package/dist/plan.js.map +1 -1
- package/dist/print.d.ts +17 -0
- package/dist/print.d.ts.map +1 -0
- package/dist/print.js +266 -0
- package/dist/print.js.map +1 -0
- package/dist/resolve.d.ts +23 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +333 -0
- package/dist/resolve.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/dist/walk.d.ts +56 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +157 -0
- package/dist/walk.js.map +1 -0
- package/package.json +1 -1
- package/src/ast.ts +126 -40
- package/src/build.ts +73 -0
- package/src/context.ts +36 -12
- package/src/engine.ts +285 -110
- package/src/errors.ts +3 -2
- package/src/index.ts +40 -14
- package/src/lexer.ts +102 -51
- package/src/optimize/analysis.ts +17 -15
- package/src/optimize/ir.ts +4 -4
- package/src/parser.ts +721 -289
- package/src/plan.ts +3 -2
- package/src/print.ts +276 -0
- package/src/resolve.ts +309 -0
- package/src/version.ts +4 -0
- 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)
|
|
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:
|
|
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:
|
|
144
|
-
const
|
|
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
|
|
189
|
-
// is the root
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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.
|
|
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`,
|
|
269
|
-
// `$ordinal
|
|
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
|
|
273
|
-
// occurrence with `$stop == "cycle"` and does not expand it
|
|
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
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
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)
|
|
286
|
-
//
|
|
287
|
-
// recurring identity is kept once, at its first position
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
307
|
-
for (const x of
|
|
308
|
-
const key = canonicalKey(
|
|
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
|
-
|
|
403
|
+
succ.push(x);
|
|
312
404
|
}
|
|
313
|
-
return
|
|
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 = (
|
|
322
|
-
const
|
|
323
|
-
const
|
|
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(
|
|
328
|
-
else if (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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
542
|
-
|
|
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):
|
|
551
|
-
return (state.rows ??= this.
|
|
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
|
-
|
|
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
|
|
656
|
-
case "first": return scopes
|
|
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
|
|
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;
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
//
|
|
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.
|
|
774
|
-
|
|
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
|
|
797
|
-
|
|
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
|
-
}
|