@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/dist/engine.js
CHANGED
|
@@ -21,10 +21,19 @@
|
|
|
21
21
|
import { DefaultContext } from "./context.js";
|
|
22
22
|
import { OqxError } from "./errors.js";
|
|
23
23
|
import { relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, } from "./semantics.js";
|
|
24
|
+
import { printTemplate } from "./print.js";
|
|
24
25
|
import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.js";
|
|
25
26
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
26
27
|
const KEY = "$key";
|
|
27
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
|
+
}
|
|
28
37
|
const UNBOUNDED = { offset: 0, limit: null };
|
|
29
38
|
export class InMemoryEngine {
|
|
30
39
|
ctx;
|
|
@@ -45,8 +54,12 @@ export class InMemoryEngine {
|
|
|
45
54
|
};
|
|
46
55
|
if (this.trace)
|
|
47
56
|
run.trace = this.trace;
|
|
48
|
-
const root = { row:
|
|
49
|
-
const
|
|
57
|
+
const root = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
|
|
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;
|
|
50
63
|
const bound = this.boundOf(query, root);
|
|
51
64
|
if (query.follow)
|
|
52
65
|
return this.runFollow(query, rows, root, bound);
|
|
@@ -86,21 +99,26 @@ export class InMemoryEngine {
|
|
|
86
99
|
break;
|
|
87
100
|
}
|
|
88
101
|
}
|
|
102
|
+
this.applyKey(kept, query);
|
|
89
103
|
this.sortScopes(kept, query.orderBy);
|
|
90
104
|
if (query.distinct)
|
|
91
105
|
kept = this.dedupByProjection(kept, query);
|
|
92
106
|
kept = sliceBound(kept, bound);
|
|
93
|
-
return this.shape(query.consumer, kept, query);
|
|
107
|
+
return this.shape(query.consumer, kept, query, single);
|
|
94
108
|
}
|
|
95
109
|
// Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
|
|
96
110
|
// is read in a row-less scope INSIDE it: a bare name is absent (there is no
|
|
97
111
|
// current item yet), `^name` is the enclosing row — exactly as in the block's
|
|
98
|
-
// body — and literals/bindings are themselves. For a top-level query
|
|
99
|
-
// is the root
|
|
112
|
+
// body — and literals/bindings are themselves. For a top-level query the
|
|
113
|
+
// scope is the root scope WITHOUT its row (SEMANTICS §18): bare names are
|
|
114
|
+
// still the named roots, but `$it` — reached through a bracket inside the
|
|
115
|
+
// bound — is absent, not the root object. Each must be a non-negative integer.
|
|
100
116
|
boundOf(b, enclosing) {
|
|
101
117
|
if (!b.limit && !b.offset)
|
|
102
118
|
return UNBOUNDED;
|
|
103
|
-
const scope = enclosing.parent === null
|
|
119
|
+
const scope = enclosing.parent === null
|
|
120
|
+
? { row: undefined, parent: null, bindings: enclosing.bindings, run: enclosing.run }
|
|
121
|
+
: rowless(enclosing);
|
|
104
122
|
const read = (e, word) => {
|
|
105
123
|
if (!e)
|
|
106
124
|
return null;
|
|
@@ -118,8 +136,60 @@ export class InMemoryEngine {
|
|
|
118
136
|
return true;
|
|
119
137
|
return this.evalWhere(where, this.enter(row, parent));
|
|
120
138
|
}
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
123
193
|
}
|
|
124
194
|
child(row, parent) {
|
|
125
195
|
return this.enter(row, parent);
|
|
@@ -145,7 +215,7 @@ export class InMemoryEngine {
|
|
|
145
215
|
runFollow(query, rows, root, bound) {
|
|
146
216
|
const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
|
|
147
217
|
const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
|
|
148
|
-
const occ = this.followWalk(seeds, query.follow, root);
|
|
218
|
+
const occ = this.followWalk(this.keyed(seeds, query, root), query.follow, query.frontier, root);
|
|
149
219
|
let scopes = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
|
|
150
220
|
if (post)
|
|
151
221
|
scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
@@ -153,7 +223,13 @@ export class InMemoryEngine {
|
|
|
153
223
|
if (query.distinct)
|
|
154
224
|
scopes = this.dedupByProjection(scopes, query);
|
|
155
225
|
scopes = sliceBound(scopes, bound);
|
|
156
|
-
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) }));
|
|
157
233
|
}
|
|
158
234
|
// Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
|
|
159
235
|
// distinct projection, preserving order. An empty projection dedups by row
|
|
@@ -165,7 +241,7 @@ export class InMemoryEngine {
|
|
|
165
241
|
const out = [];
|
|
166
242
|
for (const s of scopes) {
|
|
167
243
|
const key = proj.select.length === 0
|
|
168
|
-
? `i:${canonicalKey(this.
|
|
244
|
+
? `i:${canonicalKey(this.scopeIdentity(s))}`
|
|
169
245
|
: `p:${canonicalKey(this.projectRow(proj, s))}`;
|
|
170
246
|
if (seen.has(key))
|
|
171
247
|
continue;
|
|
@@ -175,81 +251,102 @@ export class InMemoryEngine {
|
|
|
175
251
|
return out;
|
|
176
252
|
}
|
|
177
253
|
// A bounded, per-path recursive walk. Each occurrence carries recursion
|
|
178
|
-
// metadata: `$depth` (seed = 1), a categorical `$stop`,
|
|
179
|
-
// `$ordinal
|
|
254
|
+
// metadata: `$depth` (seed = 1), a categorical `$stop`, a deterministic
|
|
255
|
+
// `$ordinal`, and `$key`, the identity it is known by. Semantics (SEMANTICS §20):
|
|
180
256
|
// • per-path — a node reached by N distinct paths yields N occurrences
|
|
181
257
|
// (unless `distinct`, which keeps the minimal (depth, path) per identity);
|
|
182
|
-
// • cycles are safe — revisiting
|
|
183
|
-
// 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;
|
|
184
260
|
// • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
|
|
185
261
|
// cycle > frontier > depth > leaf > interior; only `interior` rows expand;
|
|
186
262
|
// • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
|
|
187
263
|
// "there is unfollowed graph beyond me" signal;
|
|
188
|
-
// • identity
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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`;
|
|
192
269
|
// • a row's successors are the destinations' rows in source order — a plain
|
|
193
270
|
// relation read in the row's scope, a destination block evaluated as a
|
|
194
271
|
// select-position directive in that scope (so `^` inside it is the frontier
|
|
195
|
-
// row and its `^people` receiver resolves one scope further out)
|
|
196
|
-
//
|
|
197
|
-
// recurring identity is kept once, at its first position
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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);
|
|
208
289
|
const raw = [];
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
+
});
|
|
214
305
|
const seen = new Set();
|
|
215
|
-
const
|
|
216
|
-
for (const x of
|
|
217
|
-
const key = canonicalKey(
|
|
306
|
+
const succ = [];
|
|
307
|
+
for (const x of raw) {
|
|
308
|
+
const key = canonicalKey(x.key);
|
|
218
309
|
if (seen.has(key))
|
|
219
310
|
continue;
|
|
220
311
|
seen.add(key);
|
|
221
|
-
|
|
312
|
+
succ.push(x);
|
|
222
313
|
}
|
|
223
|
-
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)));
|
|
224
323
|
};
|
|
225
|
-
const frontierHit = (row) => follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
|
|
226
324
|
const walked = [];
|
|
227
|
-
const visit = (
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
const path = [...ancestorIds, id];
|
|
325
|
+
const visit = (k, via, depth, ancestors, ancestorIds) => {
|
|
326
|
+
const key = canonicalKey(k.key);
|
|
327
|
+
const path = [...ancestorIds, k.key];
|
|
231
328
|
let stop;
|
|
232
329
|
if (ancestors.includes(key))
|
|
233
330
|
stop = "cycle";
|
|
234
|
-
else if (frontierHit(
|
|
331
|
+
else if (frontierHit(k, via))
|
|
235
332
|
stop = "frontier";
|
|
236
|
-
else if (depth >=
|
|
333
|
+
else if (depth >= HARD_DEPTH_CAP)
|
|
237
334
|
stop = "depth";
|
|
238
335
|
else {
|
|
239
|
-
const succ = succOf(
|
|
336
|
+
const { succ, declined } = succOf(k, depth);
|
|
240
337
|
if (succ.length === 0)
|
|
241
|
-
stop = "leaf";
|
|
338
|
+
stop = declined ? "depth" : "leaf";
|
|
242
339
|
else {
|
|
243
|
-
walked.push({ row, depth, path, key, stop: "interior" });
|
|
244
|
-
for (const
|
|
245
|
-
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);
|
|
246
343
|
return;
|
|
247
344
|
}
|
|
248
345
|
}
|
|
249
|
-
walked.push({ row, depth, path, key, stop });
|
|
346
|
+
walked.push({ row: k.row, depth, path, id: k.key, key, stop });
|
|
250
347
|
};
|
|
251
|
-
for (const
|
|
252
|
-
visit(
|
|
348
|
+
for (const k of seeds)
|
|
349
|
+
visit(k, null, 1, [], []);
|
|
253
350
|
let rows = walked;
|
|
254
351
|
if (follow.distinct) {
|
|
255
352
|
// keep the minimal (depth, path) occurrence per identity key.
|
|
@@ -266,6 +363,7 @@ export class InMemoryEngine {
|
|
|
266
363
|
return rows.map((w, i) => ({
|
|
267
364
|
row: w.row,
|
|
268
365
|
meta: {
|
|
366
|
+
[KEY]: w.id,
|
|
269
367
|
$depth: w.depth,
|
|
270
368
|
$stop: w.stop,
|
|
271
369
|
$leaf: w.stop === "leaf",
|
|
@@ -274,13 +372,30 @@ export class InMemoryEngine {
|
|
|
274
372
|
},
|
|
275
373
|
}));
|
|
276
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
|
+
}
|
|
277
390
|
// ---- consumer shaping -----------------------------------------------------
|
|
278
|
-
|
|
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) {
|
|
279
394
|
switch (consumer) {
|
|
280
395
|
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
281
396
|
case "none": return { consumer, none: scopes.length === 0 };
|
|
282
397
|
case "count": return { consumer, count: scopes.length };
|
|
283
|
-
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 };
|
|
284
399
|
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]) : null };
|
|
285
400
|
case "single":
|
|
286
401
|
if (scopes.length > 1)
|
|
@@ -370,6 +485,7 @@ export class InMemoryEngine {
|
|
|
370
485
|
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
371
486
|
opRows(op, scope, bound, acc) {
|
|
372
487
|
let scopes = this.matchRows(op, scope, acc);
|
|
488
|
+
this.applyKey(scopes, op.sub);
|
|
373
489
|
this.sortScopes(scopes, op.sub.orderBy);
|
|
374
490
|
if (op.distinct)
|
|
375
491
|
scopes = this.dedupByProjection(scopes, op.sub);
|
|
@@ -434,7 +550,7 @@ export class InMemoryEngine {
|
|
|
434
550
|
if (plan.receiverStable)
|
|
435
551
|
run.stableCollections.set(op, (state = newCollection(value)));
|
|
436
552
|
else if (plan.correlated.length === 0)
|
|
437
|
-
return this.scan(this.
|
|
553
|
+
return this.scan(this.coerceRows(value, op.receiver), op, scope);
|
|
438
554
|
else if (value !== null && typeof value === "object") {
|
|
439
555
|
state = run.collections.get(value);
|
|
440
556
|
if (!state)
|
|
@@ -442,7 +558,7 @@ export class InMemoryEngine {
|
|
|
442
558
|
}
|
|
443
559
|
else {
|
|
444
560
|
run.trace?.({ kind: "fallback", reason: "not-a-collection" });
|
|
445
|
-
return this.scan(this.
|
|
561
|
+
return this.scan(this.coerceRows(value, op.receiver), op, scope);
|
|
446
562
|
}
|
|
447
563
|
}
|
|
448
564
|
state.seen++;
|
|
@@ -451,26 +567,27 @@ export class InMemoryEngine {
|
|
|
451
567
|
if (direct)
|
|
452
568
|
return direct;
|
|
453
569
|
if (state.seen >= 2 || plan.receiverStable) {
|
|
454
|
-
const probed = this.probe(plan, state, scope);
|
|
570
|
+
const probed = this.probe(plan, state, scope, op);
|
|
455
571
|
if (probed)
|
|
456
|
-
return { rows: probed, where: plan.residual };
|
|
572
|
+
return { rows: probed, where: plan.residual, single: false };
|
|
457
573
|
}
|
|
458
574
|
else
|
|
459
575
|
run.trace?.({ kind: "fallback", reason: "unstable" });
|
|
460
576
|
}
|
|
461
|
-
return this.scan(this.materialize(state), op, scope);
|
|
577
|
+
return this.scan(this.materialize(state, op), op, scope);
|
|
462
578
|
}
|
|
463
579
|
// The plain scan: the rows, re-projected by `from`, with the whole `where`.
|
|
464
|
-
|
|
465
|
-
|
|
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 };
|
|
466
583
|
}
|
|
467
584
|
reproject(rows, from, scope) {
|
|
468
585
|
for (const proj of from)
|
|
469
|
-
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));
|
|
470
587
|
return rows;
|
|
471
588
|
}
|
|
472
|
-
materialize(state) {
|
|
473
|
-
return (state.rows ??= this.
|
|
589
|
+
materialize(state, op) {
|
|
590
|
+
return (state.rows ??= this.coerceRows(state.value, op.receiver));
|
|
474
591
|
}
|
|
475
592
|
// A probe answered by a context index that yields rows directly, before the
|
|
476
593
|
// collection is materialized: one correlation is probed (`lookupOrder`) and
|
|
@@ -485,19 +602,19 @@ export class InMemoryEngine {
|
|
|
485
602
|
return null;
|
|
486
603
|
const rows = Array.from(index.lookupRows(outer.value));
|
|
487
604
|
scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
|
|
488
|
-
return { rows, where: residualWithout(plan, c) };
|
|
605
|
+
return { rows, where: residualWithout(plan, c), single: false };
|
|
489
606
|
}
|
|
490
607
|
return null;
|
|
491
608
|
}
|
|
492
609
|
// The engine's probe: one bucket per correlation, intersected smallest-first;
|
|
493
610
|
// the candidates come back in receiver order. `null` (an index that could
|
|
494
611
|
// not be built, an outer value whose evaluation threw) leaves the scan in charge.
|
|
495
|
-
probe(plan, state, scope) {
|
|
612
|
+
probe(plan, state, scope, op) {
|
|
496
613
|
const run = scope.run;
|
|
497
|
-
const rows = this.materialize(state);
|
|
614
|
+
const rows = this.materialize(state, op).rows;
|
|
498
615
|
const buckets = [];
|
|
499
616
|
for (const c of plan.correlated) {
|
|
500
|
-
const index = this.indexFor(state, c, scope);
|
|
617
|
+
const index = this.indexFor(state, c, scope, op);
|
|
501
618
|
if (!index) {
|
|
502
619
|
run.trace?.({ kind: "fallback", reason: "index" });
|
|
503
620
|
return null;
|
|
@@ -538,12 +655,12 @@ export class InMemoryEngine {
|
|
|
538
655
|
// here by evaluating the local expression in each row's scope — the same read
|
|
539
656
|
// the scan performs, so the keys are exactly what `==` would compare. A throw
|
|
540
657
|
// while building (a context that rejects a read) leaves the path un-indexed.
|
|
541
|
-
indexFor(state, c, scope) {
|
|
658
|
+
indexFor(state, c, scope, op) {
|
|
542
659
|
const key = pathKey(c.path);
|
|
543
660
|
const hit = state.indexes.get(key);
|
|
544
661
|
if (hit !== undefined)
|
|
545
662
|
return hit;
|
|
546
|
-
const rows = this.materialize(state);
|
|
663
|
+
const rows = this.materialize(state, op).rows;
|
|
547
664
|
let index = this.contextIndex(state, c);
|
|
548
665
|
let source = "context";
|
|
549
666
|
if (!index) {
|
|
@@ -569,30 +686,47 @@ export class InMemoryEngine {
|
|
|
569
686
|
return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
|
|
570
687
|
}
|
|
571
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) {
|
|
572
703
|
const sub = op.sub;
|
|
573
704
|
const bound = this.boundOf(sub, scope);
|
|
574
705
|
let scopes;
|
|
706
|
+
let single = false;
|
|
575
707
|
if (sub.follow) {
|
|
576
708
|
// The seeds are the matched rows themselves (`followWalk` enters them); a
|
|
577
709
|
// follow block is never correlated, so `where` here is the whole predicate.
|
|
578
710
|
const { rows, where } = this.access(op, plan, scope);
|
|
579
711
|
const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
|
|
580
|
-
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 }));
|
|
581
713
|
this.sortScopes(scopes, sub.orderBy);
|
|
582
714
|
if (op.distinct)
|
|
583
715
|
scopes = this.dedupByProjection(scopes, sub);
|
|
584
716
|
scopes = sliceBound(scopes, bound);
|
|
585
717
|
}
|
|
586
718
|
else {
|
|
587
|
-
|
|
719
|
+
const acc = this.access(op, plan, scope);
|
|
720
|
+
single = acc.single;
|
|
721
|
+
scopes = this.opRows(op, scope, bound, acc);
|
|
588
722
|
}
|
|
589
723
|
switch (op.op) {
|
|
590
|
-
case "collect": return scopes
|
|
591
|
-
case "first": return scopes
|
|
724
|
+
case "collect": return { scopes, single };
|
|
725
|
+
case "first": return { scopes: scopes.slice(0, 1), single };
|
|
592
726
|
case "single":
|
|
593
727
|
if (scopes.length > 1)
|
|
594
728
|
throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
|
|
595
|
-
return scopes
|
|
729
|
+
return { scopes, single };
|
|
596
730
|
default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
|
|
597
731
|
}
|
|
598
732
|
}
|
|
@@ -656,11 +790,6 @@ export class InMemoryEngine {
|
|
|
656
790
|
const r = this.evalExpr(e.recv, scope);
|
|
657
791
|
return r == null ? undefined : this.ctx.get(r, e.name);
|
|
658
792
|
}
|
|
659
|
-
case "index": {
|
|
660
|
-
const r = this.evalExpr(e.recv, scope);
|
|
661
|
-
const i = this.evalExpr(e.index, scope);
|
|
662
|
-
return r == null ? undefined : this.ctx.get(r, String(i));
|
|
663
|
-
}
|
|
664
793
|
case "call": return this.evalCall(e, scope);
|
|
665
794
|
case "unary": {
|
|
666
795
|
const v = this.evalExpr(e.expr, scope);
|
|
@@ -668,9 +797,25 @@ export class InMemoryEngine {
|
|
|
668
797
|
return !truthy(v);
|
|
669
798
|
return v == null ? undefined : -v;
|
|
670
799
|
}
|
|
800
|
+
// `x!` — the value, or an eval error naming the expression (and the row's
|
|
801
|
+
// identity when it has a scalar one). Never a filter, never a coercion.
|
|
802
|
+
case "required": {
|
|
803
|
+
const v = this.evalExpr(e.expr, scope);
|
|
804
|
+
if (v == null)
|
|
805
|
+
throw new OqxError(`\`${describe(e.expr)}!\` is absent${this.rowIdentity(scope)}`, "eval");
|
|
806
|
+
return v;
|
|
807
|
+
}
|
|
808
|
+
// A value-position directive (`jobs first { … }.pay`, the desugared `x[…]`).
|
|
809
|
+
case "op": return this.evalCollectValue(e, scope);
|
|
671
810
|
case "binary": {
|
|
672
811
|
const l = this.evalExpr(e.left, scope);
|
|
673
812
|
const r = this.evalExpr(e.right, scope);
|
|
813
|
+
if (e.op === "is" || e.op === "is not") {
|
|
814
|
+
// Identity (SEMANTICS §5b): the host's identity of each side,
|
|
815
|
+
// compared structurally — the key `distinct` and `follow` use.
|
|
816
|
+
const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
|
|
817
|
+
return e.op === "is" ? same : !same;
|
|
818
|
+
}
|
|
674
819
|
return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
|
|
675
820
|
}
|
|
676
821
|
case "logical": {
|
|
@@ -681,15 +826,37 @@ export class InMemoryEngine {
|
|
|
681
826
|
}
|
|
682
827
|
case "in": return membership(this.evalExpr(e.left, scope), this.evalExpr(e.right, scope));
|
|
683
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
|
+
}
|
|
684
848
|
}
|
|
685
849
|
}
|
|
686
850
|
// Resolve a name against ONE scope — never its ancestors. A scope provides,
|
|
687
851
|
// in order: `$it` (the scope's row itself — the current item, whatever its
|
|
688
|
-
// type, so scalar collections are queryable;
|
|
689
|
-
//
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
852
|
+
// type, so scalar collections are queryable; at the root the host's root
|
|
853
|
+
// object, since 0.18; `$value`, its pre-0.15 spelling, is an ordinary
|
|
854
|
+
// property name now); `$key` (the property key) when it is an entry scope —
|
|
855
|
+
// see `enter`; the recursion intrinsics (`$depth`, …) when it is a follow
|
|
856
|
+
// occurrence; values lifted into it by `^name:` items; then either the row's
|
|
857
|
+
// own property or, for the root scope, the context's named roots (`root(name)`
|
|
858
|
+
// — never a property of the root object, so a host with lazy roots is
|
|
859
|
+
// unaffected by what its root object holds).
|
|
693
860
|
//
|
|
694
861
|
// The two metadata steps apply only where the scope CARRIES that metadata
|
|
695
862
|
// (SEMANTICS §2, since 0.13). Anywhere else — `$key` on an ordinary row,
|
|
@@ -706,8 +873,11 @@ export class InMemoryEngine {
|
|
|
706
873
|
// 0, "") need no special case — there is no "absent, so look outward" rule.
|
|
707
874
|
resolveIn(name, scope) {
|
|
708
875
|
if (name === "$it")
|
|
709
|
-
return scope.
|
|
710
|
-
|
|
876
|
+
return scope.row;
|
|
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))
|
|
711
881
|
return scope.meta[name];
|
|
712
882
|
if (scope.lifts && Object.hasOwn(scope.lifts, name))
|
|
713
883
|
return scope.lifts[name];
|
|
@@ -715,6 +885,15 @@ export class InMemoryEngine {
|
|
|
715
885
|
return this.ctx.root(name);
|
|
716
886
|
return this.ctx.get(scope.row, name);
|
|
717
887
|
}
|
|
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).
|
|
891
|
+
rowIdentity(scope) {
|
|
892
|
+
if (scope.parent === null || scope.row == null)
|
|
893
|
+
return "";
|
|
894
|
+
const id = this.scopeIdentity(scope);
|
|
895
|
+
return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
|
|
896
|
+
}
|
|
718
897
|
evalCall(e, scope) {
|
|
719
898
|
const args = e.args.map((a) => this.evalExpr(a, scope));
|
|
720
899
|
if (e.recv === null) {
|
|
@@ -800,6 +979,12 @@ function boundedCount(n, b) {
|
|
|
800
979
|
const rest = Math.max(0, n - b.offset);
|
|
801
980
|
return b.limit == null ? rest : Math.min(rest, b.limit);
|
|
802
981
|
}
|
|
982
|
+
// The canonical text of an expression for an error message; a binding shows as
|
|
983
|
+
// its `${n}` marker (the printer refuses to turn a bound value into source).
|
|
984
|
+
function describe(e) {
|
|
985
|
+
const t = printTemplate(e);
|
|
986
|
+
return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
|
|
987
|
+
}
|
|
803
988
|
function describeReceiver(e) {
|
|
804
989
|
if (e.kind === "ident")
|
|
805
990
|
return e.name;
|
|
@@ -820,7 +1005,7 @@ function partitionRecur(w) {
|
|
|
820
1005
|
const post = [];
|
|
821
1006
|
for (const p of parts)
|
|
822
1007
|
(whereHasRecur(p) ? post : seed).push(p);
|
|
823
|
-
const rebuild = (ps) => (ps.length === 0 ? null : ps.length === 1 ? ps[0] : { kind: "and", parts: ps });
|
|
1008
|
+
const rebuild = (ps) => (ps.length === 0 ? null : ps.length === 1 ? ps[0] : { kind: "and", span: [0, 0], parts: ps });
|
|
824
1009
|
return { seed: rebuild(seed), post: rebuild(post) };
|
|
825
1010
|
}
|
|
826
1011
|
function whereHasRecur(w) {
|
|
@@ -836,19 +1021,15 @@ function exprHasRecur(e) {
|
|
|
836
1021
|
switch (e.kind) {
|
|
837
1022
|
case "ident": return RECUR.has(e.name);
|
|
838
1023
|
case "member": return exprHasRecur(e.recv);
|
|
839
|
-
case "index": return exprHasRecur(e.recv) || exprHasRecur(e.index);
|
|
840
1024
|
case "call": return (e.recv ? exprHasRecur(e.recv) : false) || e.args.some(exprHasRecur);
|
|
841
|
-
case "unary":
|
|
1025
|
+
case "unary":
|
|
1026
|
+
case "required": return exprHasRecur(e.expr);
|
|
842
1027
|
case "binary":
|
|
843
1028
|
case "logical":
|
|
844
1029
|
case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
|
|
845
1030
|
case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
|
|
1031
|
+
case "tuple": return e.items.some(exprHasRecur);
|
|
846
1032
|
default: return false;
|
|
847
1033
|
}
|
|
848
1034
|
}
|
|
849
|
-
/** Convenience: run a query with plain-object roots (the default context). */
|
|
850
|
-
export function runQuery(query, bindings, roots) {
|
|
851
|
-
const ctx = new DefaultContext(roots ?? {});
|
|
852
|
-
return new InMemoryEngine(ctx).run(query, bindings);
|
|
853
|
-
}
|
|
854
1035
|
//# sourceMappingURL=engine.js.map
|