@omgbase/oqx 0.18.0 → 0.21.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 +30 -0
- package/README.md +177 -64
- package/dist/ast.d.ts +28 -5
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +1 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +10 -3
- package/dist/build.js.map +1 -1
- package/dist/context.d.ts +19 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +6 -9
- package/dist/context.js.map +1 -1
- package/dist/engine.d.ts +12 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +263 -84
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lexer.d.ts +3 -3
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +79 -2
- 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 +18 -15
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +250 -118
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +47 -29
- package/dist/print.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +15 -8
- package/dist/resolve.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts +5 -3
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +34 -11
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +33 -6
- package/src/build.ts +12 -8
- package/src/context.ts +22 -11
- package/src/engine.ts +258 -91
- package/src/index.ts +2 -1
- package/src/lexer.ts +76 -6
- package/src/optimize/analysis.ts +17 -12
- package/src/parser.ts +227 -101
- package/src/print.ts +44 -16
- package/src/resolve.ts +15 -7
- package/src/version.ts +1 -1
- package/src/walk.ts +39 -14
package/src/engine.ts
CHANGED
|
@@ -26,15 +26,18 @@ import type { DataContext } from "./context.ts";
|
|
|
26
26
|
import { DefaultContext } from "./context.ts";
|
|
27
27
|
import { OqxError } from "./errors.ts";
|
|
28
28
|
import {
|
|
29
|
-
relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange,
|
|
29
|
+
relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, stringForm,
|
|
30
30
|
} from "./semantics.ts";
|
|
31
31
|
import type { BlockPlan, Correlation, Rule, RowIndex } from "./optimize/index.ts";
|
|
32
32
|
import { printTemplate } from "./print.ts";
|
|
33
33
|
import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.ts";
|
|
34
34
|
|
|
35
|
-
/** 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`). */
|
|
36
39
|
export type OqxResult =
|
|
37
|
-
| { consumer: "collect"; rows: unknown[] }
|
|
40
|
+
| { consumer: "collect"; rows: unknown[]; single: boolean }
|
|
38
41
|
| { consumer: "exists"; exists: boolean }
|
|
39
42
|
| { consumer: "none"; none: boolean }
|
|
40
43
|
| { consumer: "count"; count: number }
|
|
@@ -84,7 +87,7 @@ interface RunState {
|
|
|
84
87
|
interface CollectionState {
|
|
85
88
|
value: unknown;
|
|
86
89
|
seen: number;
|
|
87
|
-
rows:
|
|
90
|
+
rows: Rows | null;
|
|
88
91
|
indexes: Map<string, RowIndex | null>;
|
|
89
92
|
contextIndexes: Map<string, RowIndex | null>;
|
|
90
93
|
}
|
|
@@ -119,6 +122,20 @@ const HARD_DEPTH_CAP = 8;
|
|
|
119
122
|
// `Query` and `Subquery` carry this shape.
|
|
120
123
|
type Projection = Pick<Subquery, "select" | "values">;
|
|
121
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
|
+
|
|
122
139
|
// An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
|
|
123
140
|
interface Bound { offset: number; limit: number | null; }
|
|
124
141
|
const UNBOUNDED: Bound = { offset: 0, limit: null };
|
|
@@ -144,7 +161,11 @@ export class InMemoryEngine implements Engine {
|
|
|
144
161
|
};
|
|
145
162
|
if (this.trace) run.trace = this.trace;
|
|
146
163
|
const root: Scope = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
|
|
147
|
-
const
|
|
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;
|
|
148
169
|
|
|
149
170
|
const bound = this.boundOf(query, root);
|
|
150
171
|
if (query.follow) return this.runFollow(query, rows, root, bound);
|
|
@@ -179,10 +200,11 @@ export class InMemoryEngine implements Engine {
|
|
|
179
200
|
if (kept.length >= cap) break;
|
|
180
201
|
}
|
|
181
202
|
}
|
|
203
|
+
this.applyKey(kept, query);
|
|
182
204
|
this.sortScopes(kept, query.orderBy);
|
|
183
205
|
if (query.distinct) kept = this.dedupByProjection(kept, query);
|
|
184
206
|
kept = sliceBound(kept, bound);
|
|
185
|
-
return this.shape(query.consumer, kept, query);
|
|
207
|
+
return this.shape(query.consumer, kept, query, single);
|
|
186
208
|
}
|
|
187
209
|
|
|
188
210
|
// Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
|
|
@@ -214,8 +236,56 @@ export class InMemoryEngine implements Engine {
|
|
|
214
236
|
return this.evalWhere(where, this.enter(row, parent));
|
|
215
237
|
}
|
|
216
238
|
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
}
|
|
219
289
|
}
|
|
220
290
|
|
|
221
291
|
private child(row: unknown, parent: Scope): Scope {
|
|
@@ -243,13 +313,20 @@ export class InMemoryEngine implements Engine {
|
|
|
243
313
|
private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
|
|
244
314
|
const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
|
|
245
315
|
const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
|
|
246
|
-
const occ = this.followWalk(seeds, query.follow!, root);
|
|
316
|
+
const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
|
|
247
317
|
let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
|
|
248
318
|
if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
249
319
|
this.sortScopes(scopes, query.orderBy);
|
|
250
320
|
if (query.distinct) scopes = this.dedupByProjection(scopes, query);
|
|
251
321
|
scopes = sliceBound(scopes, bound);
|
|
252
|
-
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) }));
|
|
253
330
|
}
|
|
254
331
|
|
|
255
332
|
// Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
|
|
@@ -262,7 +339,7 @@ export class InMemoryEngine implements Engine {
|
|
|
262
339
|
const out: Scope[] = [];
|
|
263
340
|
for (const s of scopes) {
|
|
264
341
|
const key = proj.select.length === 0
|
|
265
|
-
? `i:${canonicalKey(this.
|
|
342
|
+
? `i:${canonicalKey(this.scopeIdentity(s))}`
|
|
266
343
|
: `p:${canonicalKey(this.projectRow(proj, s))}`;
|
|
267
344
|
if (seen.has(key)) continue;
|
|
268
345
|
seen.add(key);
|
|
@@ -272,79 +349,90 @@ export class InMemoryEngine implements Engine {
|
|
|
272
349
|
}
|
|
273
350
|
|
|
274
351
|
// A bounded, per-path recursive walk. Each occurrence carries recursion
|
|
275
|
-
// metadata: `$depth` (seed = 1), a categorical `$stop`,
|
|
276
|
-
// `$ordinal
|
|
352
|
+
// metadata: `$depth` (seed = 1), a categorical `$stop`, a deterministic
|
|
353
|
+
// `$ordinal`, and `$key`, the identity it is known by. Semantics (SEMANTICS §20):
|
|
277
354
|
// • per-path — a node reached by N distinct paths yields N occurrences
|
|
278
355
|
// (unless `distinct`, which keeps the minimal (depth, path) per identity);
|
|
279
|
-
// • cycles are safe — revisiting
|
|
280
|
-
// 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;
|
|
281
358
|
// • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
|
|
282
359
|
// cycle > frontier > depth > leaf > interior; only `interior` rows expand;
|
|
283
360
|
// • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
|
|
284
361
|
// "there is unfollowed graph beyond me" signal;
|
|
285
|
-
// • identity
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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`;
|
|
289
367
|
// • a row's successors are the destinations' rows in source order — a plain
|
|
290
368
|
// relation read in the row's scope, a destination block evaluated as a
|
|
291
369
|
// select-position directive in that scope (so `^` inside it is the frontier
|
|
292
|
-
// row and its `^people` receiver resolves one scope further out)
|
|
293
|
-
//
|
|
294
|
-
// recurring identity is kept once, at its first position
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
});
|
|
312
397
|
const seen = new Set<string>();
|
|
313
|
-
const
|
|
314
|
-
for (const x of
|
|
315
|
-
const key = canonicalKey(
|
|
398
|
+
const succ: (Keyed & { via: number })[] = [];
|
|
399
|
+
for (const x of raw) {
|
|
400
|
+
const key = canonicalKey(x.key);
|
|
316
401
|
if (seen.has(key)) continue;
|
|
317
402
|
seen.add(key);
|
|
318
|
-
|
|
403
|
+
succ.push(x);
|
|
319
404
|
}
|
|
320
|
-
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)));
|
|
321
412
|
};
|
|
322
|
-
const frontierHit = (row: unknown): boolean =>
|
|
323
|
-
follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
|
|
324
413
|
|
|
325
|
-
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; }
|
|
326
415
|
const walked: Walked[] = [];
|
|
327
416
|
|
|
328
|
-
const visit = (
|
|
329
|
-
const
|
|
330
|
-
const
|
|
331
|
-
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];
|
|
332
420
|
let stop: string;
|
|
333
421
|
if (ancestors.includes(key)) stop = "cycle";
|
|
334
|
-
else if (frontierHit(
|
|
335
|
-
else if (depth >=
|
|
422
|
+
else if (frontierHit(k, via)) stop = "frontier";
|
|
423
|
+
else if (depth >= HARD_DEPTH_CAP) stop = "depth";
|
|
336
424
|
else {
|
|
337
|
-
const succ = succOf(
|
|
338
|
-
if (succ.length === 0) stop = "leaf";
|
|
425
|
+
const { succ, declined } = succOf(k, depth);
|
|
426
|
+
if (succ.length === 0) stop = declined ? "depth" : "leaf";
|
|
339
427
|
else {
|
|
340
|
-
walked.push({ row, depth, path, key, stop: "interior" });
|
|
341
|
-
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);
|
|
342
430
|
return;
|
|
343
431
|
}
|
|
344
432
|
}
|
|
345
|
-
walked.push({ row, depth, path, key, stop });
|
|
433
|
+
walked.push({ row: k.row, depth, path, id: k.key, key, stop });
|
|
346
434
|
};
|
|
347
|
-
for (const
|
|
435
|
+
for (const k of seeds) visit(k, null, 1, [], []);
|
|
348
436
|
|
|
349
437
|
let rows = walked;
|
|
350
438
|
if (follow.distinct) {
|
|
@@ -361,6 +449,7 @@ export class InMemoryEngine implements Engine {
|
|
|
361
449
|
return rows.map((w, i) => ({
|
|
362
450
|
row: w.row,
|
|
363
451
|
meta: {
|
|
452
|
+
[KEY]: w.id,
|
|
364
453
|
$depth: w.depth,
|
|
365
454
|
$stop: w.stop,
|
|
366
455
|
$leaf: w.stop === "leaf",
|
|
@@ -370,14 +459,32 @@ export class InMemoryEngine implements Engine {
|
|
|
370
459
|
}));
|
|
371
460
|
}
|
|
372
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
|
+
|
|
373
478
|
// ---- consumer shaping -----------------------------------------------------
|
|
374
479
|
|
|
375
|
-
|
|
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 {
|
|
376
483
|
switch (consumer) {
|
|
377
484
|
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
378
485
|
case "none": return { consumer, none: scopes.length === 0 };
|
|
379
486
|
case "count": return { consumer, count: scopes.length };
|
|
380
|
-
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 };
|
|
381
488
|
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
|
|
382
489
|
case "single":
|
|
383
490
|
if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
|
|
@@ -462,6 +569,7 @@ export class InMemoryEngine implements Engine {
|
|
|
462
569
|
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
463
570
|
private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
|
|
464
571
|
let scopes = this.matchRows(op, scope, acc);
|
|
572
|
+
this.applyKey(scopes, op.sub);
|
|
465
573
|
this.sortScopes(scopes, op.sub.orderBy);
|
|
466
574
|
if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
|
|
467
575
|
return sliceBound(scopes, bound);
|
|
@@ -523,13 +631,13 @@ export class InMemoryEngine implements Engine {
|
|
|
523
631
|
if (!state) {
|
|
524
632
|
const value = this.evalRaw(op.receiver, scope);
|
|
525
633
|
if (plan.receiverStable) run.stableCollections.set(op, (state = newCollection(value)));
|
|
526
|
-
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);
|
|
527
635
|
else if (value !== null && typeof value === "object") {
|
|
528
636
|
state = run.collections.get(value);
|
|
529
637
|
if (!state) run.collections.set(value, (state = newCollection(value)));
|
|
530
638
|
} else {
|
|
531
639
|
run.trace?.({ kind: "fallback", reason: "not-a-collection" });
|
|
532
|
-
return this.scan(this.
|
|
640
|
+
return this.scan(this.coerceRows(value, op.receiver), op, scope);
|
|
533
641
|
}
|
|
534
642
|
}
|
|
535
643
|
state.seen++;
|
|
@@ -537,25 +645,26 @@ export class InMemoryEngine implements Engine {
|
|
|
537
645
|
const direct = this.lookupRows(plan, state, scope);
|
|
538
646
|
if (direct) return direct;
|
|
539
647
|
if (state.seen >= 2 || plan.receiverStable) {
|
|
540
|
-
const probed = this.probe(plan, state, scope);
|
|
541
|
-
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 };
|
|
542
650
|
} else run.trace?.({ kind: "fallback", reason: "unstable" });
|
|
543
651
|
}
|
|
544
|
-
return this.scan(this.materialize(state), op, scope);
|
|
652
|
+
return this.scan(this.materialize(state, op), op, scope);
|
|
545
653
|
}
|
|
546
654
|
|
|
547
655
|
// The plain scan: the rows, re-projected by `from`, with the whole `where`.
|
|
548
|
-
|
|
549
|
-
|
|
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 };
|
|
550
659
|
}
|
|
551
660
|
|
|
552
661
|
private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
|
|
553
|
-
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));
|
|
554
663
|
return rows;
|
|
555
664
|
}
|
|
556
665
|
|
|
557
|
-
private materialize(state: CollectionState):
|
|
558
|
-
return (state.rows ??= this.
|
|
666
|
+
private materialize(state: CollectionState, op: OpNode): Rows {
|
|
667
|
+
return (state.rows ??= this.coerceRows(state.value, op.receiver));
|
|
559
668
|
}
|
|
560
669
|
|
|
561
670
|
// A probe answered by a context index that yields rows directly, before the
|
|
@@ -569,7 +678,7 @@ export class InMemoryEngine implements Engine {
|
|
|
569
678
|
if (!outer) return null;
|
|
570
679
|
const rows = Array.from(index.lookupRows(outer.value));
|
|
571
680
|
scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
|
|
572
|
-
return { rows, where: residualWithout(plan, c) };
|
|
681
|
+
return { rows, where: residualWithout(plan, c), single: false };
|
|
573
682
|
}
|
|
574
683
|
return null;
|
|
575
684
|
}
|
|
@@ -577,12 +686,12 @@ export class InMemoryEngine implements Engine {
|
|
|
577
686
|
// The engine's probe: one bucket per correlation, intersected smallest-first;
|
|
578
687
|
// the candidates come back in receiver order. `null` (an index that could
|
|
579
688
|
// not be built, an outer value whose evaluation threw) leaves the scan in charge.
|
|
580
|
-
private probe(plan: BlockPlan, state: CollectionState, scope: Scope): unknown[] | null {
|
|
689
|
+
private probe(plan: BlockPlan, state: CollectionState, scope: Scope, op: OpNode): unknown[] | null {
|
|
581
690
|
const run = scope.run;
|
|
582
|
-
const rows = this.materialize(state);
|
|
691
|
+
const rows = this.materialize(state, op).rows;
|
|
583
692
|
const buckets: (readonly number[])[] = [];
|
|
584
693
|
for (const c of plan.correlated) {
|
|
585
|
-
const index = this.indexFor(state, c, scope);
|
|
694
|
+
const index = this.indexFor(state, c, scope, op);
|
|
586
695
|
if (!index) { run.trace?.({ kind: "fallback", reason: "index" }); return null; }
|
|
587
696
|
const outer = this.outerValue(c, scope);
|
|
588
697
|
if (!outer) return null;
|
|
@@ -615,11 +724,11 @@ export class InMemoryEngine implements Engine {
|
|
|
615
724
|
// here by evaluating the local expression in each row's scope — the same read
|
|
616
725
|
// the scan performs, so the keys are exactly what `==` would compare. A throw
|
|
617
726
|
// while building (a context that rejects a read) leaves the path un-indexed.
|
|
618
|
-
private indexFor(state: CollectionState, c: Correlation, scope: Scope): RowIndex | null {
|
|
727
|
+
private indexFor(state: CollectionState, c: Correlation, scope: Scope, op: OpNode): RowIndex | null {
|
|
619
728
|
const key = pathKey(c.path);
|
|
620
729
|
const hit = state.indexes.get(key);
|
|
621
730
|
if (hit !== undefined) return hit;
|
|
622
|
-
const rows = this.materialize(state);
|
|
731
|
+
const rows = this.materialize(state, op).rows;
|
|
623
732
|
let index = this.contextIndex(state, c);
|
|
624
733
|
let source: "context" | "engine" = "context";
|
|
625
734
|
if (!index) {
|
|
@@ -643,27 +752,45 @@ export class InMemoryEngine implements Engine {
|
|
|
643
752
|
}
|
|
644
753
|
|
|
645
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 } {
|
|
646
770
|
const sub = op.sub;
|
|
647
771
|
const bound = this.boundOf(sub, scope);
|
|
648
772
|
let scopes: Scope[];
|
|
773
|
+
let single = false;
|
|
649
774
|
if (sub.follow) {
|
|
650
775
|
// The seeds are the matched rows themselves (`followWalk` enters them); a
|
|
651
776
|
// follow block is never correlated, so `where` here is the whole predicate.
|
|
652
777
|
const { rows, where } = this.access(op, plan, scope);
|
|
653
778
|
const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
|
|
654
|
-
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 }));
|
|
655
780
|
this.sortScopes(scopes, sub.orderBy);
|
|
656
781
|
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
657
782
|
scopes = sliceBound(scopes, bound);
|
|
658
783
|
} else {
|
|
659
|
-
|
|
784
|
+
const acc = this.access(op, plan, scope);
|
|
785
|
+
single = acc.single;
|
|
786
|
+
scopes = this.opRows(op, scope, bound, acc);
|
|
660
787
|
}
|
|
661
788
|
switch (op.op) {
|
|
662
|
-
case "collect": return scopes
|
|
663
|
-
case "first": return scopes
|
|
789
|
+
case "collect": return { scopes, single };
|
|
790
|
+
case "first": return { scopes: scopes.slice(0, 1), single };
|
|
664
791
|
case "single":
|
|
665
792
|
if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
|
|
666
|
-
return scopes
|
|
793
|
+
return { scopes, single };
|
|
667
794
|
default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
|
|
668
795
|
}
|
|
669
796
|
}
|
|
@@ -745,9 +872,9 @@ export class InMemoryEngine implements Engine {
|
|
|
745
872
|
const l = this.evalExpr(e.left, scope);
|
|
746
873
|
const r = this.evalExpr(e.right, scope);
|
|
747
874
|
if (e.op === "is" || e.op === "is not") {
|
|
748
|
-
// Identity (SEMANTICS §5b): the
|
|
875
|
+
// Identity (SEMANTICS §5b): the host's identity of each side,
|
|
749
876
|
// compared structurally — the key `distinct` and `follow` use.
|
|
750
|
-
const same = canonicalKey(this.
|
|
877
|
+
const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
|
|
751
878
|
return e.op === "is" ? same : !same;
|
|
752
879
|
}
|
|
753
880
|
return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
|
|
@@ -762,6 +889,41 @@ export class InMemoryEngine implements Engine {
|
|
|
762
889
|
e.lo == null ? null : this.evalExpr(e.lo, scope),
|
|
763
890
|
e.hi == null ? null : this.evalExpr(e.hi, scope),
|
|
764
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
|
+
}
|
|
907
|
+
// A template literal (SEMANTICS §7b): the text parts verbatim and each
|
|
908
|
+
// interpolated value's string form (§7), concatenated. Every interpolation
|
|
909
|
+
// is evaluated, in order; a list, a record or a range is an eval error (it
|
|
910
|
+
// has no string form the language specifies); an absent value makes the
|
|
911
|
+
// whole literal absent, as `+` does.
|
|
912
|
+
case "template": {
|
|
913
|
+
let out = "";
|
|
914
|
+
let absent = false;
|
|
915
|
+
for (const part of e.parts) {
|
|
916
|
+
if (typeof part === "string") { out += part; continue; }
|
|
917
|
+
const v = this.evalExpr(part, scope);
|
|
918
|
+
if (v == null) { absent = true; continue; }
|
|
919
|
+
if (typeof v === "object") {
|
|
920
|
+
const what = isRange(v) ? "a range" : Array.isArray(v) || Symbol.iterator in v ? "a list" : "a record";
|
|
921
|
+
throw new OqxError(`cannot interpolate \`\${${describe(part)}}\` in a template literal: it is ${what} — only a string, a number, a boolean or an absent value has a string form`, "eval");
|
|
922
|
+
}
|
|
923
|
+
out += stringForm(v)!;
|
|
924
|
+
}
|
|
925
|
+
return absent ? undefined : out;
|
|
926
|
+
}
|
|
765
927
|
}
|
|
766
928
|
}
|
|
767
929
|
|
|
@@ -791,18 +953,20 @@ export class InMemoryEngine implements Engine {
|
|
|
791
953
|
// 0, "") need no special case — there is no "absent, so look outward" rule.
|
|
792
954
|
private resolveIn(name: string, scope: Scope): unknown {
|
|
793
955
|
if (name === "$it") return scope.row;
|
|
794
|
-
|
|
956
|
+
// `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
|
|
957
|
+
if (name === KEY) return this.scopeIdentity(scope);
|
|
958
|
+
if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
|
|
795
959
|
if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
|
|
796
960
|
if (scope.parent === null) return this.ctx.root(name);
|
|
797
961
|
return this.ctx.get(scope.row, name);
|
|
798
962
|
}
|
|
799
963
|
|
|
800
|
-
// ` on <identity>` for a required-value error when the scope
|
|
801
|
-
// scalar identity (
|
|
802
|
-
// root (the root object is not a row of the data).
|
|
964
|
+
// ` on <identity>` for a required-value error when the scope's row has a
|
|
965
|
+
// scalar identity (`$key`: the host's, an entry's key, a `$key:` projection);
|
|
966
|
+
// empty otherwise, and at the root (the root object is not a row of the data).
|
|
803
967
|
private rowIdentity(scope: Scope): string {
|
|
804
968
|
if (scope.parent === null || scope.row == null) return "";
|
|
805
|
-
const id = this.
|
|
969
|
+
const id = this.scopeIdentity(scope);
|
|
806
970
|
return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
|
|
807
971
|
}
|
|
808
972
|
|
|
@@ -822,8 +986,9 @@ export class InMemoryEngine implements Engine {
|
|
|
822
986
|
|
|
823
987
|
interface Occurrence { row: unknown; meta: Record<string, unknown>; }
|
|
824
988
|
|
|
825
|
-
// A block's accessed rows
|
|
826
|
-
|
|
989
|
+
// A block's accessed rows, the predicate still to evaluate over each, and
|
|
990
|
+
// whether the receiver was a single object (SEMANTICS §3).
|
|
991
|
+
interface Access { rows: unknown[]; where: Where | null; single: boolean; }
|
|
827
992
|
|
|
828
993
|
// ---- free helpers -----------------------------------------------------------
|
|
829
994
|
|
|
@@ -947,6 +1112,8 @@ function exprHasRecur(e: Expr): boolean {
|
|
|
947
1112
|
case "unary": case "required": return exprHasRecur(e.expr);
|
|
948
1113
|
case "binary": case "logical": case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
|
|
949
1114
|
case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
|
|
1115
|
+
case "tuple": return e.items.some(exprHasRecur);
|
|
1116
|
+
case "template": return e.parts.some((p) => typeof p !== "string" && exprHasRecur(p));
|
|
950
1117
|
default: return false;
|
|
951
1118
|
}
|
|
952
1119
|
}
|
package/src/index.ts
CHANGED
|
@@ -116,7 +116,8 @@ export function runQuery(query: Query, bindings: readonly unknown[], roots: unkn
|
|
|
116
116
|
|
|
117
117
|
function unwrap(result: OqxResult): unknown {
|
|
118
118
|
switch (result.consumer) {
|
|
119
|
-
|
|
119
|
+
// A `collect` over one object is that one record, or `null` (SEMANTICS §3).
|
|
120
|
+
case "collect": return result.single ? (result.rows[0] ?? null) : result.rows;
|
|
120
121
|
case "exists": return result.exists;
|
|
121
122
|
case "none": return result.none;
|
|
122
123
|
case "count": return result.count;
|