@omgbase/oqx 0.18.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 +24 -0
- package/README.md +149 -64
- package/dist/ast.d.ts +21 -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 +7 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -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 +235 -83
- 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/optimize/analysis.d.ts +3 -2
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +14 -15
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +196 -114
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +13 -13
- package/dist/print.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +11 -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 +26 -8
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +23 -6
- package/src/build.ts +8 -7
- package/src/context.ts +22 -11
- package/src/engine.ts +236 -90
- package/src/index.ts +2 -1
- package/src/optimize/analysis.ts +13 -12
- package/src/parser.ts +180 -96
- package/src/print.ts +12 -10
- package/src/resolve.ts +11 -7
- package/src/version.ts +1 -1
- package/src/walk.ts +32 -11
package/dist/engine.js
CHANGED
|
@@ -26,6 +26,14 @@ import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, res
|
|
|
26
26
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
27
27
|
const KEY = "$key";
|
|
28
28
|
const HARD_DEPTH_CAP = 8;
|
|
29
|
+
// The `$key: expr` item of a projection, if any — the identity the rows it
|
|
30
|
+
// yields carry (SEMANTICS §16, since 0.20).
|
|
31
|
+
function keyItem(proj) {
|
|
32
|
+
for (const it of proj.select)
|
|
33
|
+
if (it.kind === "field" && it.lift === 0 && it.name === KEY)
|
|
34
|
+
return it;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
29
37
|
const UNBOUNDED = { offset: 0, limit: null };
|
|
30
38
|
export class InMemoryEngine {
|
|
31
39
|
ctx;
|
|
@@ -47,7 +55,11 @@ export class InMemoryEngine {
|
|
|
47
55
|
if (this.trace)
|
|
48
56
|
run.trace = this.trace;
|
|
49
57
|
const root = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
|
|
50
|
-
const
|
|
58
|
+
const src = this.coerceRows(this.evalRaw(query.source, root), query.source);
|
|
59
|
+
const rows = this.reproject(src.rows, query.from, root);
|
|
60
|
+
// One object as the source yields one record (SEMANTICS §3) — unless a
|
|
61
|
+
// `from` re-projection or a walk fans it out.
|
|
62
|
+
const single = src.single && query.from.length === 0 && !query.follow;
|
|
51
63
|
const bound = this.boundOf(query, root);
|
|
52
64
|
if (query.follow)
|
|
53
65
|
return this.runFollow(query, rows, root, bound);
|
|
@@ -87,11 +99,12 @@ export class InMemoryEngine {
|
|
|
87
99
|
break;
|
|
88
100
|
}
|
|
89
101
|
}
|
|
102
|
+
this.applyKey(kept, query);
|
|
90
103
|
this.sortScopes(kept, query.orderBy);
|
|
91
104
|
if (query.distinct)
|
|
92
105
|
kept = this.dedupByProjection(kept, query);
|
|
93
106
|
kept = sliceBound(kept, bound);
|
|
94
|
-
return this.shape(query.consumer, kept, query);
|
|
107
|
+
return this.shape(query.consumer, kept, query, single);
|
|
95
108
|
}
|
|
96
109
|
// Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
|
|
97
110
|
// is read in a row-less scope INSIDE it: a bare name is absent (there is no
|
|
@@ -123,8 +136,60 @@ export class InMemoryEngine {
|
|
|
123
136
|
return true;
|
|
124
137
|
return this.evalWhere(where, this.enter(row, parent));
|
|
125
138
|
}
|
|
126
|
-
|
|
127
|
-
|
|
139
|
+
// The rows of a value in row position (SEMANTICS §3): absent → none; an array
|
|
140
|
+
// (or a host iterable) → its elements, a collection; an object → one row, and
|
|
141
|
+
// the value is SINGLE — a block over it yields one result rather than a list;
|
|
142
|
+
// a scalar (a string, a number, a boolean) or a range → an eval error. A
|
|
143
|
+
// context says which of its objects are single (`isSingle`); without it, any
|
|
144
|
+
// non-iterable object is.
|
|
145
|
+
coerceRows(v, what) {
|
|
146
|
+
if (v == null)
|
|
147
|
+
return { rows: [], single: false };
|
|
148
|
+
if (Array.isArray(v))
|
|
149
|
+
return { rows: v, single: false };
|
|
150
|
+
if (typeof v !== "object" || isRange(v)) {
|
|
151
|
+
const kind = isRange(v) ? "a range" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : `a ${typeof v}`;
|
|
152
|
+
const shown = isRange(v) ? "" : ` (${JSON.stringify(v)})`;
|
|
153
|
+
const subject = what ? `\`${describe(what)}\` is` : "the receiver is";
|
|
154
|
+
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");
|
|
155
|
+
}
|
|
156
|
+
const rows = Array.from(this.ctx.toRows(v));
|
|
157
|
+
const single = this.ctx.isSingle ? this.ctx.isSingle(v) : !(Symbol.iterator in v);
|
|
158
|
+
return { rows, single };
|
|
159
|
+
}
|
|
160
|
+
rowsOf(v, what = null) {
|
|
161
|
+
return this.coerceRows(v, what).rows;
|
|
162
|
+
}
|
|
163
|
+
// The host's identity of a row (SEMANTICS §16): an entry is known by its key;
|
|
164
|
+
// any other row by the context's `identityOf`, else by its structural value.
|
|
165
|
+
hostIdentity(row) {
|
|
166
|
+
if (isEntry(row))
|
|
167
|
+
return row.key;
|
|
168
|
+
if (this.ctx.identityOf)
|
|
169
|
+
return this.ctx.identityOf(row);
|
|
170
|
+
if (this.ctx.identity)
|
|
171
|
+
return this.ctx.identity(row);
|
|
172
|
+
return row;
|
|
173
|
+
}
|
|
174
|
+
// The identity a scope's row is known by — `$key`: the identity the walk or a
|
|
175
|
+
// `$key:` projection gave it (`meta`), else the host's.
|
|
176
|
+
scopeIdentity(scope) {
|
|
177
|
+
if (scope.meta && Object.hasOwn(scope.meta, KEY))
|
|
178
|
+
return scope.meta[KEY];
|
|
179
|
+
if (scope.parent === null && scope.row === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
return this.hostIdentity(scope.row);
|
|
182
|
+
}
|
|
183
|
+
// A projection's `$key: expr` item gives the rows it yields that identity:
|
|
184
|
+
// evaluate it on each kept scope and record it as the scope's `$key`.
|
|
185
|
+
applyKey(scopes, proj) {
|
|
186
|
+
const item = keyItem(proj);
|
|
187
|
+
if (!item)
|
|
188
|
+
return;
|
|
189
|
+
for (const s of scopes) {
|
|
190
|
+
const key = this.evalExpr(item.expr, s);
|
|
191
|
+
s.meta = { ...(s.meta ?? {}), [KEY]: key };
|
|
192
|
+
}
|
|
128
193
|
}
|
|
129
194
|
child(row, parent) {
|
|
130
195
|
return this.enter(row, parent);
|
|
@@ -150,7 +215,7 @@ export class InMemoryEngine {
|
|
|
150
215
|
runFollow(query, rows, root, bound) {
|
|
151
216
|
const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
|
|
152
217
|
const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
|
|
153
|
-
const occ = this.followWalk(seeds, query.follow, root);
|
|
218
|
+
const occ = this.followWalk(this.keyed(seeds, query, root), query.follow, query.frontier, root);
|
|
154
219
|
let scopes = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
|
|
155
220
|
if (post)
|
|
156
221
|
scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
@@ -158,7 +223,13 @@ export class InMemoryEngine {
|
|
|
158
223
|
if (query.distinct)
|
|
159
224
|
scopes = this.dedupByProjection(scopes, query);
|
|
160
225
|
scopes = sliceBound(scopes, bound);
|
|
161
|
-
return this.shape(query.consumer, scopes, query);
|
|
226
|
+
return this.shape(query.consumer, scopes, query, false);
|
|
227
|
+
}
|
|
228
|
+
// The seeds of a walk with their identities: the body's `$key: expr` item,
|
|
229
|
+
// read in each seed's scope, else the host's identity (SEMANTICS §20).
|
|
230
|
+
keyed(rows, proj, parent) {
|
|
231
|
+
const item = keyItem(proj);
|
|
232
|
+
return rows.map((row) => ({ row, key: item ? this.evalExpr(item.expr, this.enter(row, parent)) : this.hostIdentity(row) }));
|
|
162
233
|
}
|
|
163
234
|
// Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
|
|
164
235
|
// distinct projection, preserving order. An empty projection dedups by row
|
|
@@ -170,7 +241,7 @@ export class InMemoryEngine {
|
|
|
170
241
|
const out = [];
|
|
171
242
|
for (const s of scopes) {
|
|
172
243
|
const key = proj.select.length === 0
|
|
173
|
-
? `i:${canonicalKey(this.
|
|
244
|
+
? `i:${canonicalKey(this.scopeIdentity(s))}`
|
|
174
245
|
: `p:${canonicalKey(this.projectRow(proj, s))}`;
|
|
175
246
|
if (seen.has(key))
|
|
176
247
|
continue;
|
|
@@ -180,81 +251,102 @@ export class InMemoryEngine {
|
|
|
180
251
|
return out;
|
|
181
252
|
}
|
|
182
253
|
// A bounded, per-path recursive walk. Each occurrence carries recursion
|
|
183
|
-
// metadata: `$depth` (seed = 1), a categorical `$stop`,
|
|
184
|
-
// `$ordinal
|
|
254
|
+
// metadata: `$depth` (seed = 1), a categorical `$stop`, a deterministic
|
|
255
|
+
// `$ordinal`, and `$key`, the identity it is known by. Semantics (SEMANTICS §20):
|
|
185
256
|
// • per-path — a node reached by N distinct paths yields N occurrences
|
|
186
257
|
// (unless `distinct`, which keeps the minimal (depth, path) per identity);
|
|
187
|
-
// • cycles are safe — revisiting
|
|
188
|
-
// occurrence with `$stop == "cycle"` and does not expand it
|
|
258
|
+
// • cycles are safe — revisiting an identity already on the current path
|
|
259
|
+
// admits ONE occurrence with `$stop == "cycle"` and does not expand it;
|
|
189
260
|
// • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
|
|
190
261
|
// cycle > frontier > depth > leaf > interior; only `interior` rows expand;
|
|
191
262
|
// • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
|
|
192
263
|
// "there is unfollowed graph beyond me" signal;
|
|
193
|
-
// • identity
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
264
|
+
// • identity is `$key` (since 0.20): a seed's is the body's `$key:` item or
|
|
265
|
+
// the host's identity; a successor's is its destination block's `$key:`
|
|
266
|
+
// item, else the host's identity of the yielded row. Identities compare
|
|
267
|
+
// structurally (`canonicalKey`); `$ordinal` paths compare component-wise
|
|
268
|
+
// (`comparePath`), so `10` follows `9`;
|
|
197
269
|
// • a row's successors are the destinations' rows in source order — a plain
|
|
198
270
|
// relation read in the row's scope, a destination block evaluated as a
|
|
199
271
|
// select-position directive in that scope (so `^` inside it is the frontier
|
|
200
|
-
// row and its `^people` receiver resolves one scope further out)
|
|
201
|
-
//
|
|
202
|
-
// recurring identity is kept once, at its first position
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
272
|
+
// row and its `^people` receiver resolves one scope further out), shaped by
|
|
273
|
+
// the block's items other than `$key` — then unioned by identity within
|
|
274
|
+
// the step (a recurring identity is kept once, at its first position, and
|
|
275
|
+
// the destination that reached it first governs the occurrence);
|
|
276
|
+
// • a destination block's `depth n` (0.20) makes it decline to expand a
|
|
277
|
+
// frontier row whose `$depth >= n`; a row with no successors some
|
|
278
|
+
// destination declined is `depth`, with none declined `leaf`; the hard
|
|
279
|
+
// cap 8 is every destination's ceiling;
|
|
280
|
+
// • a destination block's `frontier P` marks the occurrences IT reached for
|
|
281
|
+
// which `P` holds (seeds are reached by none); `P` reads the occurrence's
|
|
282
|
+
// own scope with `^` the walk's enclosing scope, as the body does;
|
|
283
|
+
// • the body's own `frontier P` (`seedFrontier`, 0.20) marks the SEEDS for
|
|
284
|
+
// which `P` holds, read in the seed's scope exactly like the body's `where`.
|
|
285
|
+
followWalk(seeds, follow, seedFrontier, parent) {
|
|
286
|
+
const frontierScope = (k) => this.enter(k.row, parent, { meta: { [KEY]: k.key } });
|
|
287
|
+
const succOf = (k, depth) => {
|
|
288
|
+
const rowScope = frontierScope(k);
|
|
213
289
|
const raw = [];
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
290
|
+
let declined = false;
|
|
291
|
+
follow.destinations.forEach((dest, via) => {
|
|
292
|
+
if (dest.kind === "op") {
|
|
293
|
+
if (dest.sub.depth !== null && depth >= dest.sub.depth) {
|
|
294
|
+
declined = true;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
for (const x of this.destinationRows(dest, rowScope))
|
|
298
|
+
raw.push({ ...x, via });
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
for (const row of this.rowsOf(this.evalRaw(dest, rowScope), dest))
|
|
302
|
+
raw.push({ row, key: this.hostIdentity(row), via });
|
|
303
|
+
}
|
|
304
|
+
});
|
|
219
305
|
const seen = new Set();
|
|
220
|
-
const
|
|
221
|
-
for (const x of
|
|
222
|
-
const key = canonicalKey(
|
|
306
|
+
const succ = [];
|
|
307
|
+
for (const x of raw) {
|
|
308
|
+
const key = canonicalKey(x.key);
|
|
223
309
|
if (seen.has(key))
|
|
224
310
|
continue;
|
|
225
311
|
seen.add(key);
|
|
226
|
-
|
|
312
|
+
succ.push(x);
|
|
227
313
|
}
|
|
228
|
-
return
|
|
314
|
+
return { succ, declined };
|
|
315
|
+
};
|
|
316
|
+
const frontierHit = (k, via) => {
|
|
317
|
+
if (via === null)
|
|
318
|
+
return seedFrontier !== null && truthy(this.evalExpr(seedFrontier, frontierScope(k)));
|
|
319
|
+
const dest = follow.destinations[via];
|
|
320
|
+
if (dest.kind !== "op" || !dest.sub.frontier)
|
|
321
|
+
return false;
|
|
322
|
+
return truthy(this.evalExpr(dest.sub.frontier, frontierScope(k)));
|
|
229
323
|
};
|
|
230
|
-
const frontierHit = (row) => follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
|
|
231
324
|
const walked = [];
|
|
232
|
-
const visit = (
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
const path = [...ancestorIds, id];
|
|
325
|
+
const visit = (k, via, depth, ancestors, ancestorIds) => {
|
|
326
|
+
const key = canonicalKey(k.key);
|
|
327
|
+
const path = [...ancestorIds, k.key];
|
|
236
328
|
let stop;
|
|
237
329
|
if (ancestors.includes(key))
|
|
238
330
|
stop = "cycle";
|
|
239
|
-
else if (frontierHit(
|
|
331
|
+
else if (frontierHit(k, via))
|
|
240
332
|
stop = "frontier";
|
|
241
|
-
else if (depth >=
|
|
333
|
+
else if (depth >= HARD_DEPTH_CAP)
|
|
242
334
|
stop = "depth";
|
|
243
335
|
else {
|
|
244
|
-
const succ = succOf(
|
|
336
|
+
const { succ, declined } = succOf(k, depth);
|
|
245
337
|
if (succ.length === 0)
|
|
246
|
-
stop = "leaf";
|
|
338
|
+
stop = declined ? "depth" : "leaf";
|
|
247
339
|
else {
|
|
248
|
-
walked.push({ row, depth, path, key, stop: "interior" });
|
|
249
|
-
for (const
|
|
250
|
-
visit(
|
|
340
|
+
walked.push({ row: k.row, depth, path, id: k.key, key, stop: "interior" });
|
|
341
|
+
for (const x of succ)
|
|
342
|
+
visit(x, x.via, depth + 1, [...ancestors, key], path);
|
|
251
343
|
return;
|
|
252
344
|
}
|
|
253
345
|
}
|
|
254
|
-
walked.push({ row, depth, path, key, stop });
|
|
346
|
+
walked.push({ row: k.row, depth, path, id: k.key, key, stop });
|
|
255
347
|
};
|
|
256
|
-
for (const
|
|
257
|
-
visit(
|
|
348
|
+
for (const k of seeds)
|
|
349
|
+
visit(k, null, 1, [], []);
|
|
258
350
|
let rows = walked;
|
|
259
351
|
if (follow.distinct) {
|
|
260
352
|
// keep the minimal (depth, path) occurrence per identity key.
|
|
@@ -271,6 +363,7 @@ export class InMemoryEngine {
|
|
|
271
363
|
return rows.map((w, i) => ({
|
|
272
364
|
row: w.row,
|
|
273
365
|
meta: {
|
|
366
|
+
[KEY]: w.id,
|
|
274
367
|
$depth: w.depth,
|
|
275
368
|
$stop: w.stop,
|
|
276
369
|
$leaf: w.stop === "leaf",
|
|
@@ -279,13 +372,30 @@ export class InMemoryEngine {
|
|
|
279
372
|
},
|
|
280
373
|
}));
|
|
281
374
|
}
|
|
375
|
+
// A destination block's successors for one frontier row, each with its
|
|
376
|
+
// identity: the block's rows (its `where`, order, `distinct`, bound and
|
|
377
|
+
// consumer applied — `first`/`single` yield at most one), shaped by its items
|
|
378
|
+
// other than `$key` (the raw rows when none remain), keyed by its `$key:`
|
|
379
|
+
// item read in the row's scope, else by the host's identity of the row yielded.
|
|
380
|
+
destinationRows(dest, rowScope) {
|
|
381
|
+
const { scopes } = this.evalCollectScopes(dest, rowScope, this.planOf(dest, rowScope), true);
|
|
382
|
+
const key = keyItem(dest.sub);
|
|
383
|
+
const shape = key ? { select: dest.sub.select.filter((it) => it !== key), values: dest.sub.values } : dest.sub;
|
|
384
|
+
return scopes.map((s) => {
|
|
385
|
+
const row = this.projectRow(shape, s);
|
|
386
|
+
const id = key ? this.evalExpr(key.expr, s) : shape.select.length === 0 ? this.scopeIdentity(s) : this.hostIdentity(row);
|
|
387
|
+
return { row, key: id };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
282
390
|
// ---- consumer shaping -----------------------------------------------------
|
|
283
|
-
|
|
391
|
+
// `single` — the receiver was one object: a `collect` yields its one record
|
|
392
|
+
// (or nothing) rather than a one-element list (SEMANTICS §3, §15).
|
|
393
|
+
shape(consumer, scopes, proj, single) {
|
|
284
394
|
switch (consumer) {
|
|
285
395
|
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
286
396
|
case "none": return { consumer, none: scopes.length === 0 };
|
|
287
397
|
case "count": return { consumer, count: scopes.length };
|
|
288
|
-
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
|
|
398
|
+
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)), single };
|
|
289
399
|
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]) : null };
|
|
290
400
|
case "single":
|
|
291
401
|
if (scopes.length > 1)
|
|
@@ -375,6 +485,7 @@ export class InMemoryEngine {
|
|
|
375
485
|
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
376
486
|
opRows(op, scope, bound, acc) {
|
|
377
487
|
let scopes = this.matchRows(op, scope, acc);
|
|
488
|
+
this.applyKey(scopes, op.sub);
|
|
378
489
|
this.sortScopes(scopes, op.sub.orderBy);
|
|
379
490
|
if (op.distinct)
|
|
380
491
|
scopes = this.dedupByProjection(scopes, op.sub);
|
|
@@ -439,7 +550,7 @@ export class InMemoryEngine {
|
|
|
439
550
|
if (plan.receiverStable)
|
|
440
551
|
run.stableCollections.set(op, (state = newCollection(value)));
|
|
441
552
|
else if (plan.correlated.length === 0)
|
|
442
|
-
return this.scan(this.
|
|
553
|
+
return this.scan(this.coerceRows(value, op.receiver), op, scope);
|
|
443
554
|
else if (value !== null && typeof value === "object") {
|
|
444
555
|
state = run.collections.get(value);
|
|
445
556
|
if (!state)
|
|
@@ -447,7 +558,7 @@ export class InMemoryEngine {
|
|
|
447
558
|
}
|
|
448
559
|
else {
|
|
449
560
|
run.trace?.({ kind: "fallback", reason: "not-a-collection" });
|
|
450
|
-
return this.scan(this.
|
|
561
|
+
return this.scan(this.coerceRows(value, op.receiver), op, scope);
|
|
451
562
|
}
|
|
452
563
|
}
|
|
453
564
|
state.seen++;
|
|
@@ -456,26 +567,27 @@ export class InMemoryEngine {
|
|
|
456
567
|
if (direct)
|
|
457
568
|
return direct;
|
|
458
569
|
if (state.seen >= 2 || plan.receiverStable) {
|
|
459
|
-
const probed = this.probe(plan, state, scope);
|
|
570
|
+
const probed = this.probe(plan, state, scope, op);
|
|
460
571
|
if (probed)
|
|
461
|
-
return { rows: probed, where: plan.residual };
|
|
572
|
+
return { rows: probed, where: plan.residual, single: false };
|
|
462
573
|
}
|
|
463
574
|
else
|
|
464
575
|
run.trace?.({ kind: "fallback", reason: "unstable" });
|
|
465
576
|
}
|
|
466
|
-
return this.scan(this.materialize(state), op, scope);
|
|
577
|
+
return this.scan(this.materialize(state, op), op, scope);
|
|
467
578
|
}
|
|
468
579
|
// The plain scan: the rows, re-projected by `from`, with the whole `where`.
|
|
469
|
-
|
|
470
|
-
|
|
580
|
+
// A `from` re-projection fans a single receiver out: the block is plural.
|
|
581
|
+
scan(src, op, scope) {
|
|
582
|
+
return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 };
|
|
471
583
|
}
|
|
472
584
|
reproject(rows, from, scope) {
|
|
473
585
|
for (const proj of from)
|
|
474
|
-
rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
|
|
586
|
+
rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope)), proj));
|
|
475
587
|
return rows;
|
|
476
588
|
}
|
|
477
|
-
materialize(state) {
|
|
478
|
-
return (state.rows ??= this.
|
|
589
|
+
materialize(state, op) {
|
|
590
|
+
return (state.rows ??= this.coerceRows(state.value, op.receiver));
|
|
479
591
|
}
|
|
480
592
|
// A probe answered by a context index that yields rows directly, before the
|
|
481
593
|
// collection is materialized: one correlation is probed (`lookupOrder`) and
|
|
@@ -490,19 +602,19 @@ export class InMemoryEngine {
|
|
|
490
602
|
return null;
|
|
491
603
|
const rows = Array.from(index.lookupRows(outer.value));
|
|
492
604
|
scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
|
|
493
|
-
return { rows, where: residualWithout(plan, c) };
|
|
605
|
+
return { rows, where: residualWithout(plan, c), single: false };
|
|
494
606
|
}
|
|
495
607
|
return null;
|
|
496
608
|
}
|
|
497
609
|
// The engine's probe: one bucket per correlation, intersected smallest-first;
|
|
498
610
|
// the candidates come back in receiver order. `null` (an index that could
|
|
499
611
|
// not be built, an outer value whose evaluation threw) leaves the scan in charge.
|
|
500
|
-
probe(plan, state, scope) {
|
|
612
|
+
probe(plan, state, scope, op) {
|
|
501
613
|
const run = scope.run;
|
|
502
|
-
const rows = this.materialize(state);
|
|
614
|
+
const rows = this.materialize(state, op).rows;
|
|
503
615
|
const buckets = [];
|
|
504
616
|
for (const c of plan.correlated) {
|
|
505
|
-
const index = this.indexFor(state, c, scope);
|
|
617
|
+
const index = this.indexFor(state, c, scope, op);
|
|
506
618
|
if (!index) {
|
|
507
619
|
run.trace?.({ kind: "fallback", reason: "index" });
|
|
508
620
|
return null;
|
|
@@ -543,12 +655,12 @@ export class InMemoryEngine {
|
|
|
543
655
|
// here by evaluating the local expression in each row's scope — the same read
|
|
544
656
|
// the scan performs, so the keys are exactly what `==` would compare. A throw
|
|
545
657
|
// while building (a context that rejects a read) leaves the path un-indexed.
|
|
546
|
-
indexFor(state, c, scope) {
|
|
658
|
+
indexFor(state, c, scope, op) {
|
|
547
659
|
const key = pathKey(c.path);
|
|
548
660
|
const hit = state.indexes.get(key);
|
|
549
661
|
if (hit !== undefined)
|
|
550
662
|
return hit;
|
|
551
|
-
const rows = this.materialize(state);
|
|
663
|
+
const rows = this.materialize(state, op).rows;
|
|
552
664
|
let index = this.contextIndex(state, c);
|
|
553
665
|
let source = "context";
|
|
554
666
|
if (!index) {
|
|
@@ -574,30 +686,47 @@ export class InMemoryEngine {
|
|
|
574
686
|
return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
|
|
575
687
|
}
|
|
576
688
|
evalCollectValuePlanned(op, plan, scope) {
|
|
689
|
+
const { scopes, single } = this.evalCollectScopes(op, scope, plan);
|
|
690
|
+
switch (op.op) {
|
|
691
|
+
// Over a single receiver (one object) a `collect` is the record or absent,
|
|
692
|
+
// not a one-element list (SEMANTICS §3).
|
|
693
|
+
case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
|
|
694
|
+
default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]) : null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// The rows a select-position directive yields, as scopes, reduced by its
|
|
698
|
+
// consumer (`first`/`single` keep at most one; `single` raises on more), and
|
|
699
|
+
// whether its receiver was a single object. `destination` marks a follow
|
|
700
|
+
// destination block: its `frontier` is the destination's (read by the outer
|
|
701
|
+
// walk), never a seed frontier for a `follow` of its own.
|
|
702
|
+
evalCollectScopes(op, scope, plan = this.planOf(op, scope), destination = false) {
|
|
577
703
|
const sub = op.sub;
|
|
578
704
|
const bound = this.boundOf(sub, scope);
|
|
579
705
|
let scopes;
|
|
706
|
+
let single = false;
|
|
580
707
|
if (sub.follow) {
|
|
581
708
|
// The seeds are the matched rows themselves (`followWalk` enters them); a
|
|
582
709
|
// follow block is never correlated, so `where` here is the whole predicate.
|
|
583
710
|
const { rows, where } = this.access(op, plan, scope);
|
|
584
711
|
const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
|
|
585
|
-
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
|
|
712
|
+
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 }));
|
|
586
713
|
this.sortScopes(scopes, sub.orderBy);
|
|
587
714
|
if (op.distinct)
|
|
588
715
|
scopes = this.dedupByProjection(scopes, sub);
|
|
589
716
|
scopes = sliceBound(scopes, bound);
|
|
590
717
|
}
|
|
591
718
|
else {
|
|
592
|
-
|
|
719
|
+
const acc = this.access(op, plan, scope);
|
|
720
|
+
single = acc.single;
|
|
721
|
+
scopes = this.opRows(op, scope, bound, acc);
|
|
593
722
|
}
|
|
594
723
|
switch (op.op) {
|
|
595
|
-
case "collect": return scopes
|
|
596
|
-
case "first": return scopes
|
|
724
|
+
case "collect": return { scopes, single };
|
|
725
|
+
case "first": return { scopes: scopes.slice(0, 1), single };
|
|
597
726
|
case "single":
|
|
598
727
|
if (scopes.length > 1)
|
|
599
728
|
throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
|
|
600
|
-
return scopes
|
|
729
|
+
return { scopes, single };
|
|
601
730
|
default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
|
|
602
731
|
}
|
|
603
732
|
}
|
|
@@ -682,9 +811,9 @@ export class InMemoryEngine {
|
|
|
682
811
|
const l = this.evalExpr(e.left, scope);
|
|
683
812
|
const r = this.evalExpr(e.right, scope);
|
|
684
813
|
if (e.op === "is" || e.op === "is not") {
|
|
685
|
-
// Identity (SEMANTICS §5b): the
|
|
814
|
+
// Identity (SEMANTICS §5b): the host's identity of each side,
|
|
686
815
|
// compared structurally — the key `distinct` and `follow` use.
|
|
687
|
-
const same = canonicalKey(this.
|
|
816
|
+
const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
|
|
688
817
|
return e.op === "is" ? same : !same;
|
|
689
818
|
}
|
|
690
819
|
return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
|
|
@@ -697,6 +826,25 @@ export class InMemoryEngine {
|
|
|
697
826
|
}
|
|
698
827
|
case "in": return membership(this.evalExpr(e.left, scope), this.evalExpr(e.right, scope));
|
|
699
828
|
case "range": return makeRange(e.lo == null ? null : this.evalExpr(e.lo, scope), e.hi == null ? null : this.evalExpr(e.hi, scope), e.exclusiveEnd);
|
|
829
|
+
// A tuple (SEMANTICS §3b): the elements in order, concatenated with ONE
|
|
830
|
+
// level of flattening — a collection contributes its items, anything else
|
|
831
|
+
// itself; an absent (`undefined`) element contributes nothing, a `null`
|
|
832
|
+
// one `null`; duplicates are kept.
|
|
833
|
+
case "tuple": {
|
|
834
|
+
const out = [];
|
|
835
|
+
for (const item of e.items) {
|
|
836
|
+
const v = this.evalExpr(item, scope);
|
|
837
|
+
if (v === undefined)
|
|
838
|
+
continue;
|
|
839
|
+
if (Array.isArray(v))
|
|
840
|
+
out.push(...v);
|
|
841
|
+
else if (v !== null && typeof v === "object" && !isEntry(v) && !isRange(v) && Symbol.iterator in v)
|
|
842
|
+
out.push(...v);
|
|
843
|
+
else
|
|
844
|
+
out.push(v);
|
|
845
|
+
}
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
700
848
|
}
|
|
701
849
|
}
|
|
702
850
|
// Resolve a name against ONE scope — never its ancestors. A scope provides,
|
|
@@ -726,7 +874,10 @@ export class InMemoryEngine {
|
|
|
726
874
|
resolveIn(name, scope) {
|
|
727
875
|
if (name === "$it")
|
|
728
876
|
return scope.row;
|
|
729
|
-
|
|
877
|
+
// `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
|
|
878
|
+
if (name === KEY)
|
|
879
|
+
return this.scopeIdentity(scope);
|
|
880
|
+
if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name))
|
|
730
881
|
return scope.meta[name];
|
|
731
882
|
if (scope.lifts && Object.hasOwn(scope.lifts, name))
|
|
732
883
|
return scope.lifts[name];
|
|
@@ -734,13 +885,13 @@ export class InMemoryEngine {
|
|
|
734
885
|
return this.ctx.root(name);
|
|
735
886
|
return this.ctx.get(scope.row, name);
|
|
736
887
|
}
|
|
737
|
-
// ` on <identity>` for a required-value error when the scope
|
|
738
|
-
// scalar identity (
|
|
739
|
-
// root (the root object is not a row of the data).
|
|
888
|
+
// ` on <identity>` for a required-value error when the scope's row has a
|
|
889
|
+
// scalar identity (`$key`: the host's, an entry's key, a `$key:` projection);
|
|
890
|
+
// empty otherwise, and at the root (the root object is not a row of the data).
|
|
740
891
|
rowIdentity(scope) {
|
|
741
892
|
if (scope.parent === null || scope.row == null)
|
|
742
893
|
return "";
|
|
743
|
-
const id = this.
|
|
894
|
+
const id = this.scopeIdentity(scope);
|
|
744
895
|
return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
|
|
745
896
|
}
|
|
746
897
|
evalCall(e, scope) {
|
|
@@ -877,6 +1028,7 @@ function exprHasRecur(e) {
|
|
|
877
1028
|
case "logical":
|
|
878
1029
|
case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
|
|
879
1030
|
case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
|
|
1031
|
+
case "tuple": return e.items.some(exprHasRecur);
|
|
880
1032
|
default: return false;
|
|
881
1033
|
}
|
|
882
1034
|
}
|