@omgbase/oqx 0.8.0 → 0.10.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/README.md +61 -7
- package/dist/adapters/sqlite.d.ts.map +1 -1
- package/dist/adapters/sqlite.js +5 -3
- package/dist/adapters/sqlite.js.map +1 -1
- package/dist/ast.d.ts +14 -2
- package/dist/ast.d.ts.map +1 -1
- package/dist/engine.d.ts +6 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +118 -43
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +48 -9
- package/dist/parser.js.map +1 -1
- package/dist/semantics.d.ts +15 -0
- package/dist/semantics.d.ts.map +1 -1
- package/dist/semantics.js +34 -0
- package/dist/semantics.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/sqlite.ts +5 -3
- package/src/ast.ts +14 -2
- package/src/engine.ts +115 -37
- package/src/index.ts +3 -2
- package/src/parser.ts +42 -9
- package/src/semantics.ts +42 -0
package/src/ast.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// current row (property navigation on the host object model), resolved at run
|
|
5
5
|
// time rather than against a fixed schema.
|
|
6
6
|
|
|
7
|
-
/** Query consumers — how a (sub)query's row set is shaped.
|
|
8
|
-
|
|
7
|
+
/** Query consumers — how a (sub)query's row set is shaped. `none` is the
|
|
8
|
+
* zero-cardinality complement of `exists` (true iff the block yields no rows). */
|
|
9
|
+
export type Consumer = "collect" | "exists" | "none" | "count" | "first" | "single";
|
|
9
10
|
|
|
10
11
|
/** Comparison operators usable in a `count { … } <op> <int>` test. */
|
|
11
12
|
export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
|
|
@@ -77,6 +78,14 @@ export interface Subquery {
|
|
|
77
78
|
* row's result itself rather than being wrapped in a `{ name: value }` record,
|
|
78
79
|
* so `name values` yields `["Bob", …]` and `$value values` yields the rows. */
|
|
79
80
|
values?: boolean;
|
|
81
|
+
/** `limit N` / `offset N` — bound the row set AFTER where/order/distinct and
|
|
82
|
+
* BEFORE the consumer reduces it, so `count { … limit 5 }` is at most 5 and
|
|
83
|
+
* `first { … offset 1 }` is the second row. Each is a value expression
|
|
84
|
+
* (a literal, a `${…}` binding, or an outer reference) read as part of the
|
|
85
|
+
* block — `^n` is the enclosing row's field, as everywhere inside `{ … }` —
|
|
86
|
+
* and must yield a non-negative integer. */
|
|
87
|
+
limit?: Expr;
|
|
88
|
+
offset?: Expr;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
/** The where-clause boolean tree: OQX owns &&/||/!/grouping so consumer ops
|
|
@@ -101,4 +110,7 @@ export interface Query {
|
|
|
101
110
|
distinct?: boolean;
|
|
102
111
|
/** `values` — scalar projection mode (see Subquery). */
|
|
103
112
|
values?: boolean;
|
|
113
|
+
/** `limit N` / `offset N` (see Subquery); evaluated at the root scope. */
|
|
114
|
+
limit?: Expr;
|
|
115
|
+
offset?: Expr;
|
|
104
116
|
}
|
package/src/engine.ts
CHANGED
|
@@ -19,13 +19,14 @@ import type { DataContext } from "./context.ts";
|
|
|
19
19
|
import { DefaultContext } from "./context.ts";
|
|
20
20
|
import { OqxError } from "./errors.ts";
|
|
21
21
|
import {
|
|
22
|
-
equals, relate, arith, membership, truthy, toNumber, compareForSort, makeRange,
|
|
22
|
+
equals, relate, arith, membership, truthy, toNumber, compareForSort, makeRange, isEntry,
|
|
23
23
|
} from "./semantics.ts";
|
|
24
24
|
|
|
25
25
|
/** The shaped result of a top-level query, discriminated by consumer. */
|
|
26
26
|
export type OqxResult =
|
|
27
27
|
| { consumer: "collect"; rows: unknown[] }
|
|
28
28
|
| { consumer: "exists"; exists: boolean }
|
|
29
|
+
| { consumer: "none"; none: boolean }
|
|
29
30
|
| { consumer: "count"; count: number }
|
|
30
31
|
| { consumer: "first"; row: unknown | null }
|
|
31
32
|
| { consumer: "single"; row: unknown | null };
|
|
@@ -38,8 +39,9 @@ export interface Engine {
|
|
|
38
39
|
// One query scope: the row under evaluation plus the chain of enclosing scopes
|
|
39
40
|
// that `^` walks. The root scope (parent === null) has no row; its names are the
|
|
40
41
|
// context's named roots. `lifts` holds values bound INTO this scope by `^name:`
|
|
41
|
-
// items in nested blocks; `meta` holds
|
|
42
|
-
// occurrence
|
|
42
|
+
// items in nested blocks; `meta` holds the scope's intrinsics: recursion
|
|
43
|
+
// metadata for a follow occurrence, and `$key` for an entry scope (a row that
|
|
44
|
+
// arrived as an `entries()` entry — see `enter`).
|
|
43
45
|
interface Scope {
|
|
44
46
|
row: unknown;
|
|
45
47
|
parent: Scope | null;
|
|
@@ -49,12 +51,17 @@ interface Scope {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
54
|
+
const KEY = "$key";
|
|
52
55
|
const HARD_DEPTH_CAP = 8;
|
|
53
56
|
|
|
54
57
|
// What a scope projects to: the select list plus the `values` mode flag. Both
|
|
55
58
|
// `Query` and `Subquery` carry this shape.
|
|
56
59
|
type Projection = Pick<Subquery, "select" | "values">;
|
|
57
60
|
|
|
61
|
+
// An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
|
|
62
|
+
interface Bound { offset: number; limit: number | null; }
|
|
63
|
+
const UNBOUNDED: Bound = { offset: 0, limit: null };
|
|
64
|
+
|
|
58
65
|
export class InMemoryEngine implements Engine {
|
|
59
66
|
private ctx: DataContext;
|
|
60
67
|
|
|
@@ -69,26 +76,34 @@ export class InMemoryEngine implements Engine {
|
|
|
69
76
|
rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
|
|
70
77
|
}
|
|
71
78
|
|
|
72
|
-
|
|
79
|
+
const bound = this.boundOf(query, root);
|
|
80
|
+
if (query.follow) return this.runFollow(query, rows, root, bound);
|
|
73
81
|
|
|
74
82
|
// Consumer-directed short-circuits (skipped under `distinct`, which must
|
|
75
|
-
// materialize + dedup by projection before reducing).
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
// materialize + dedup by projection before reducing). Counting never
|
|
84
|
+
// materializes rows; exists/none stop as soon as the bound is known to be
|
|
85
|
+
// non-empty (the (offset+1)th match — or the first, when unbounded).
|
|
86
|
+
if (!query.distinct && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
|
|
87
|
+
const need = query.consumer === "count" ? Infinity : bound.offset + 1;
|
|
88
|
+
let n = 0;
|
|
89
|
+
for (const r of rows) {
|
|
90
|
+
if (this.matches(query.where, r, root)) { n++; if (n >= need) break; }
|
|
91
|
+
}
|
|
92
|
+
const m = boundedCount(n, bound);
|
|
93
|
+
if (query.consumer === "count") return { consumer: "count", count: m };
|
|
94
|
+
if (query.consumer === "exists") return { consumer: "exists", exists: m > 0 };
|
|
95
|
+
return { consumer: "none", none: m === 0 };
|
|
84
96
|
}
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
98
|
+
// first/single over an unordered, non-distinct set need only the rows up to
|
|
99
|
+
// the bound: offset + 1 (first) / offset + 2 (single, to detect a second).
|
|
100
|
+
const want = query.consumer === "first" ? 1 : query.consumer === "single" ? 2 : Infinity;
|
|
101
|
+
const cap = !query.orderBy && !query.distinct && want !== Infinity
|
|
102
|
+
? bound.offset + Math.min(want, bound.limit ?? want)
|
|
88
103
|
: Infinity;
|
|
89
104
|
let kept: Scope[] = [];
|
|
90
105
|
for (const r of rows) {
|
|
91
|
-
const s
|
|
106
|
+
const s = this.enter(r, root, { lifts: {} });
|
|
92
107
|
if (!query.where || this.evalWhere(query.where, s)) {
|
|
93
108
|
kept.push(s);
|
|
94
109
|
if (kept.length >= cap) break;
|
|
@@ -96,13 +111,33 @@ export class InMemoryEngine implements Engine {
|
|
|
96
111
|
}
|
|
97
112
|
this.sortScopes(kept, query.orderBy);
|
|
98
113
|
if (query.distinct) kept = this.dedupByProjection(kept, query);
|
|
114
|
+
kept = sliceBound(kept, bound);
|
|
99
115
|
return this.shape(query.consumer, kept, query);
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
// Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
|
|
119
|
+
// is read in a row-less scope INSIDE it: a bare name is absent (there is no
|
|
120
|
+
// current item yet), `^name` is the enclosing row — exactly as in the block's
|
|
121
|
+
// body — and literals/bindings are themselves. For a top-level query `scope`
|
|
122
|
+
// is the root. Each must be a non-negative integer.
|
|
123
|
+
private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
|
|
124
|
+
if (!b.limit && !b.offset) return UNBOUNDED;
|
|
125
|
+
const scope: Scope = enclosing.parent === null ? enclosing : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
|
|
126
|
+
const read = (e: Expr | undefined, word: string): number | null => {
|
|
127
|
+
if (!e) return null;
|
|
128
|
+
const v = this.evalExpr(e, scope);
|
|
129
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
130
|
+
throw new OqxError(`${word} must be a non-negative integer (got ${JSON.stringify(v ?? null)})`, "eval");
|
|
131
|
+
}
|
|
132
|
+
return v;
|
|
133
|
+
};
|
|
134
|
+
return { offset: read(b.offset, "offset") ?? 0, limit: read(b.limit, "limit") };
|
|
135
|
+
}
|
|
136
|
+
|
|
102
137
|
// A where match that needs no lift capture (exists/count fast paths).
|
|
103
138
|
private matches(where: Where | null, row: unknown, parent: Scope): boolean {
|
|
104
139
|
if (!where) return true;
|
|
105
|
-
return this.evalWhere(where,
|
|
140
|
+
return this.evalWhere(where, this.enter(row, parent));
|
|
106
141
|
}
|
|
107
142
|
|
|
108
143
|
private rowsOf(v: unknown): unknown[] {
|
|
@@ -110,19 +145,36 @@ export class InMemoryEngine implements Engine {
|
|
|
110
145
|
}
|
|
111
146
|
|
|
112
147
|
private child(row: unknown, parent: Scope): Scope {
|
|
113
|
-
return
|
|
148
|
+
return this.enter(row, parent);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Make the scope for a row. An `entries()` entry is unwrapped here: the scope's
|
|
152
|
+
// row is the property's VALUE (so `$value` and bare names read it) and the key
|
|
153
|
+
// becomes the `$key` intrinsic in `meta`. Every place a row becomes a scope
|
|
154
|
+
// goes through this, so entries behave the same at the top level, in nested
|
|
155
|
+
// blocks, as `from` re-projections, and as follow seeds.
|
|
156
|
+
private enter(row: unknown, parent: Scope, extra: { lifts?: Record<string, unknown>; meta?: Record<string, unknown> } = {}): Scope {
|
|
157
|
+
const s: Scope = { row, parent, bindings: parent.bindings };
|
|
158
|
+
if (extra.lifts) s.lifts = extra.lifts;
|
|
159
|
+
if (extra.meta) s.meta = extra.meta;
|
|
160
|
+
if (isEntry(row)) {
|
|
161
|
+
s.row = row.value;
|
|
162
|
+
s.meta = { ...(s.meta ?? {}), [KEY]: row.key };
|
|
163
|
+
}
|
|
164
|
+
return s;
|
|
114
165
|
}
|
|
115
166
|
|
|
116
167
|
// ---- follow ---------------------------------------------------------------
|
|
117
168
|
|
|
118
|
-
private runFollow(query: Query, rows: unknown[], root: Scope): OqxResult {
|
|
169
|
+
private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
|
|
119
170
|
const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
|
|
120
171
|
const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
|
|
121
172
|
const occ = this.followWalk(seeds, query.follow!, root);
|
|
122
|
-
let scopes: Scope[] = occ.map((o) => (
|
|
173
|
+
let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
|
|
123
174
|
if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
124
175
|
this.sortScopes(scopes, query.orderBy);
|
|
125
176
|
if (query.distinct) scopes = this.dedupByProjection(scopes, query);
|
|
177
|
+
scopes = sliceBound(scopes, bound);
|
|
126
178
|
return this.shape(query.consumer, scopes, query);
|
|
127
179
|
}
|
|
128
180
|
|
|
@@ -158,7 +210,7 @@ export class InMemoryEngine implements Engine {
|
|
|
158
210
|
// `ctx.identity(row)`.
|
|
159
211
|
private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
|
|
160
212
|
const cap = follow.depth ?? HARD_DEPTH_CAP;
|
|
161
|
-
const scopeFor = (row: unknown): Scope => (
|
|
213
|
+
const scopeFor = (row: unknown): Scope => this.enter(row, parent);
|
|
162
214
|
const keyOf = (row: unknown): string =>
|
|
163
215
|
String(follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(row));
|
|
164
216
|
const succOf = (row: unknown): unknown[] => {
|
|
@@ -220,6 +272,7 @@ export class InMemoryEngine implements Engine {
|
|
|
220
272
|
private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
|
|
221
273
|
switch (consumer) {
|
|
222
274
|
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
275
|
+
case "none": return { consumer, none: scopes.length === 0 };
|
|
223
276
|
case "count": return { consumer, count: scopes.length };
|
|
224
277
|
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
|
|
225
278
|
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
|
|
@@ -262,9 +315,15 @@ export class InMemoryEngine implements Engine {
|
|
|
262
315
|
|
|
263
316
|
private evalWhereOp(op: OpNode, scope: Scope): boolean {
|
|
264
317
|
if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
|
|
265
|
-
|
|
318
|
+
const bound = this.boundOf(op.sub, scope);
|
|
319
|
+
if (op.op === "exists" || op.op === "none") {
|
|
320
|
+
// Unbounded: stop at the first match (dedup cannot change emptiness).
|
|
321
|
+
// Bounded: the offset/limit decide emptiness, so materialize the set.
|
|
322
|
+
const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
|
|
323
|
+
return op.op === "exists" ? any : !any;
|
|
324
|
+
}
|
|
266
325
|
if (op.op === "collect") {
|
|
267
|
-
const matched = this.
|
|
326
|
+
const matched = this.opRows(op, scope, bound);
|
|
268
327
|
for (const item of op.sub.select) {
|
|
269
328
|
if (item.kind !== "field") continue;
|
|
270
329
|
// Bind `item.lift` scopes out: `^` = the collect's own scope, `^^` its
|
|
@@ -280,12 +339,18 @@ export class InMemoryEngine implements Engine {
|
|
|
280
339
|
return matched.length > 0;
|
|
281
340
|
}
|
|
282
341
|
if (op.op === "count") {
|
|
283
|
-
|
|
284
|
-
if (op.distinct) rows = this.dedupByProjection(rows, op.sub);
|
|
285
|
-
const n = rows.length;
|
|
342
|
+
const n = this.opRows(op, scope, bound).length;
|
|
286
343
|
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
287
344
|
}
|
|
288
|
-
return this.
|
|
345
|
+
return this.opRows(op, scope, bound).length > 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
349
|
+
private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
|
|
350
|
+
let scopes = this.matchRows(op, scope);
|
|
351
|
+
this.sortScopes(scopes, op.sub.orderBy);
|
|
352
|
+
if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
|
|
353
|
+
return sliceBound(scopes, bound);
|
|
289
354
|
}
|
|
290
355
|
|
|
291
356
|
// Short-circuiting existence check over a consumer receiver.
|
|
@@ -298,7 +363,7 @@ export class InMemoryEngine implements Engine {
|
|
|
298
363
|
let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
|
|
299
364
|
for (const proj of op.sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
|
|
300
365
|
for (const r of rows) {
|
|
301
|
-
const s
|
|
366
|
+
const s = this.enter(r, scope, { lifts: {} });
|
|
302
367
|
if (!op.sub.where || this.evalWhere(op.sub.where, s)) yield s;
|
|
303
368
|
}
|
|
304
369
|
}
|
|
@@ -310,17 +375,19 @@ export class InMemoryEngine implements Engine {
|
|
|
310
375
|
// A select-position collect/first/single, optionally recursive via `follow`.
|
|
311
376
|
private evalCollectValue(op: OpNode, scope: Scope): unknown {
|
|
312
377
|
const sub = op.sub;
|
|
378
|
+
const bound = this.boundOf(sub, scope);
|
|
313
379
|
let scopes: Scope[];
|
|
314
380
|
if (sub.follow) {
|
|
315
381
|
let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
|
|
316
382
|
for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
|
|
317
|
-
const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!,
|
|
318
|
-
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => (
|
|
383
|
+
const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, this.enter(r, scope, { lifts: {} }))) : rows;
|
|
384
|
+
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
|
|
385
|
+
this.sortScopes(scopes, sub.orderBy);
|
|
386
|
+
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
387
|
+
scopes = sliceBound(scopes, bound);
|
|
319
388
|
} else {
|
|
320
|
-
scopes = this.
|
|
389
|
+
scopes = this.opRows(op, scope, bound);
|
|
321
390
|
}
|
|
322
|
-
this.sortScopes(scopes, sub.orderBy);
|
|
323
|
-
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
324
391
|
switch (op.op) {
|
|
325
392
|
case "collect": return scopes.map((s) => this.projectRow(sub, s));
|
|
326
393
|
case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
|
|
@@ -402,9 +469,10 @@ export class InMemoryEngine implements Engine {
|
|
|
402
469
|
// Resolve a name against ONE scope — never its ancestors. A scope provides,
|
|
403
470
|
// in order: `$value` (the scope's row itself — the current item, whatever its
|
|
404
471
|
// type, so scalar collections are queryable; absent at the root, which has no
|
|
405
|
-
// row); the
|
|
406
|
-
//
|
|
407
|
-
//
|
|
472
|
+
// row); `$key` (the property key, for an entry scope only — see `enter`); the
|
|
473
|
+
// recursion intrinsics (`$depth`, …) when it is a follow occurrence; values
|
|
474
|
+
// lifted into it by `^name:` items; then either the row's own property or,
|
|
475
|
+
// for the root scope (no row), the context's named roots.
|
|
408
476
|
//
|
|
409
477
|
// A name the scope lacks is simply absent (undefined). It does NOT fall
|
|
410
478
|
// through to an enclosing scope, so a query's meaning never depends on which
|
|
@@ -414,7 +482,7 @@ export class InMemoryEngine implements Engine {
|
|
|
414
482
|
// 0, "") need no special case — there is no "absent, so look outward" rule.
|
|
415
483
|
private resolveIn(name: string, scope: Scope): unknown {
|
|
416
484
|
if (name === "$value") return scope.parent === null ? undefined : scope.row;
|
|
417
|
-
if (RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
|
|
485
|
+
if (name === KEY || RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
|
|
418
486
|
if (scope.lifts && name in scope.lifts) return scope.lifts[name];
|
|
419
487
|
if (scope.parent === null) return this.ctx.root(name);
|
|
420
488
|
return this.ctx.get(scope.row, name);
|
|
@@ -459,6 +527,16 @@ function compareCount(n: number, cmp: { op: string; value: number }): boolean {
|
|
|
459
527
|
return relate(cmp.op, n, cmp.value);
|
|
460
528
|
}
|
|
461
529
|
|
|
530
|
+
// Apply a bound to an ordered row set / to a match count.
|
|
531
|
+
function sliceBound<T>(rows: T[], b: Bound): T[] {
|
|
532
|
+
if (b === UNBOUNDED) return rows;
|
|
533
|
+
return rows.slice(b.offset, b.limit == null ? undefined : b.offset + b.limit);
|
|
534
|
+
}
|
|
535
|
+
function boundedCount(n: number, b: Bound): number {
|
|
536
|
+
const rest = Math.max(0, n - b.offset);
|
|
537
|
+
return b.limit == null ? rest : Math.min(rest, b.limit);
|
|
538
|
+
}
|
|
539
|
+
|
|
462
540
|
// Order `&&` conjuncts so cheap scalar leaves run before consumer-op leaves.
|
|
463
541
|
function orderByCost(parts: Where[]): Where[] {
|
|
464
542
|
return [...parts].sort((a, b) => whereCost(a) - whereCost(b));
|
package/src/index.ts
CHANGED
|
@@ -43,8 +43,8 @@ export type * from "./ast.ts";
|
|
|
43
43
|
const templateCache = new WeakMap<TemplateStringsArray, Query>();
|
|
44
44
|
|
|
45
45
|
/** The OQX tagged template. Returns the query result shaped by its consumer:
|
|
46
|
-
* an array for `collect` (the default), a boolean for `exists`, a
|
|
47
|
-
* `count`, or a single record / null for `first` / `single`. */
|
|
46
|
+
* an array for `collect` (the default), a boolean for `exists` / `none`, a
|
|
47
|
+
* number for `count`, or a single record / null for `first` / `single`. */
|
|
48
48
|
export function oqx(strings: TemplateStringsArray, ...values: unknown[]): unknown {
|
|
49
49
|
let query = templateCache.get(strings);
|
|
50
50
|
if (!query) {
|
|
@@ -82,6 +82,7 @@ function unwrap(result: OqxResult): unknown {
|
|
|
82
82
|
switch (result.consumer) {
|
|
83
83
|
case "collect": return result.rows;
|
|
84
84
|
case "exists": return result.exists;
|
|
85
|
+
case "none": return result.none;
|
|
85
86
|
case "count": return result.count;
|
|
86
87
|
case "first": case "single": return result.row;
|
|
87
88
|
}
|
package/src/parser.ts
CHANGED
|
@@ -20,12 +20,13 @@ import type {
|
|
|
20
20
|
Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, Consumer, RelOp,
|
|
21
21
|
} from "./ast.ts";
|
|
22
22
|
|
|
23
|
-
const CONSUMERS = new Set<string>(["collect", "exists", "count", "first", "single"]);
|
|
23
|
+
const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
|
|
24
24
|
// Contextual clause words lexed as bare idents; an open-ended range must stop
|
|
25
25
|
// before them rather than consume them as its high bound.
|
|
26
26
|
const CLAUSE_WORDS = new Set<string>([
|
|
27
|
-
"collect", "exists", "count", "first", "single",
|
|
27
|
+
"collect", "exists", "none", "count", "first", "single",
|
|
28
28
|
"order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
|
|
29
|
+
"limit", "offset",
|
|
29
30
|
]);
|
|
30
31
|
const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
31
32
|
const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
@@ -50,6 +51,8 @@ interface BodyClauses {
|
|
|
50
51
|
follow: Follow | null;
|
|
51
52
|
distinct: boolean;
|
|
52
53
|
values: boolean;
|
|
54
|
+
limit: Expr | null;
|
|
55
|
+
offset: Expr | null;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
class Parser {
|
|
@@ -91,6 +94,7 @@ class Parser {
|
|
|
91
94
|
follow: directive.sub.follow,
|
|
92
95
|
distinct: directive.distinct ?? false,
|
|
93
96
|
values: directive.sub.values ?? false,
|
|
97
|
+
...bounds(directive.sub),
|
|
94
98
|
};
|
|
95
99
|
}
|
|
96
100
|
if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
|
|
@@ -111,6 +115,7 @@ class Parser {
|
|
|
111
115
|
follow: body.follow,
|
|
112
116
|
distinct: body.distinct,
|
|
113
117
|
values: body.values,
|
|
118
|
+
...bounds(body),
|
|
114
119
|
};
|
|
115
120
|
}
|
|
116
121
|
|
|
@@ -128,6 +133,8 @@ class Parser {
|
|
|
128
133
|
let follow: Follow | null = null;
|
|
129
134
|
let distinct = false;
|
|
130
135
|
let values = false;
|
|
136
|
+
let limit: Expr | null = null;
|
|
137
|
+
let offset: Expr | null = null;
|
|
131
138
|
let sawWhere = false, sawSelect = false, sawOrder = false;
|
|
132
139
|
|
|
133
140
|
while (!this.at("eof") && !this.at("rbrace")) {
|
|
@@ -155,6 +162,13 @@ class Parser {
|
|
|
155
162
|
({ items: select, values } = this.parseProjection());
|
|
156
163
|
continue;
|
|
157
164
|
}
|
|
165
|
+
if (this.atBound()) {
|
|
166
|
+
const word = this.next().value;
|
|
167
|
+
const e = this.parsePostfix();
|
|
168
|
+
if (word === "limit") { if (limit) this.fail("duplicate `limit` clause"); limit = e; }
|
|
169
|
+
else { if (offset) this.fail("duplicate `offset` clause"); offset = e; }
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
158
172
|
if (orderByAllowed && this.atOrderBy()) {
|
|
159
173
|
if (sawOrder) this.fail("duplicate `order by` clause");
|
|
160
174
|
sawOrder = true;
|
|
@@ -174,9 +188,19 @@ class Parser {
|
|
|
174
188
|
({ items: select, values } = this.parseProjection());
|
|
175
189
|
continue;
|
|
176
190
|
}
|
|
177
|
-
this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/follow`);
|
|
191
|
+
this.fail(`unexpected ${this.tokDesc()} — expected from/where/select${orderByAllowed ? "/order by" : ""}/limit/offset/follow`);
|
|
178
192
|
}
|
|
179
|
-
return { froms, where, select, orderBy, follow, distinct, values };
|
|
193
|
+
return { froms, where, select, orderBy, follow, distinct, values, limit, offset };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `limit <n>` / `offset <n>` — the word must be followed by something that can
|
|
197
|
+
// be a bound (a number, a binding, or an outer reference), so a field that
|
|
198
|
+
// happens to be called `limit` still projects/filters as a bare name.
|
|
199
|
+
private atBound(): boolean {
|
|
200
|
+
const t = this.peek();
|
|
201
|
+
if (t.type !== "ident" || (t.value !== "limit" && t.value !== "offset")) return false;
|
|
202
|
+
const nx = this.peekAt(1);
|
|
203
|
+
return !!nx && (nx.type === "number" || nx.type === "binding" || nx.type === "caret");
|
|
180
204
|
}
|
|
181
205
|
|
|
182
206
|
// Decide, by syntactic shape only, whether a leading unkeyworded run is a
|
|
@@ -270,7 +294,8 @@ class Parser {
|
|
|
270
294
|
// A receiver/source: a `${…}` binding, or a dotted identifier navigation chain
|
|
271
295
|
// whose head may be an outer reference (`^rel`, `^^root.rel`) — since a bare
|
|
272
296
|
// name is the current row's own property, an enclosing row's relation or a
|
|
273
|
-
// named root is only reachable as a receiver through
|
|
297
|
+
// named root is only reachable as a receiver through `^` — or a free-function
|
|
298
|
+
// call (`entries(prefs)`), so a computed collection can be consumed directly.
|
|
274
299
|
private parseReceiver(): Expr {
|
|
275
300
|
if (this.at("binding")) return { kind: "binding", index: this.next().index! };
|
|
276
301
|
const levels = this.parseCarets();
|
|
@@ -286,10 +311,12 @@ class Parser {
|
|
|
286
311
|
}
|
|
287
312
|
|
|
288
313
|
// A dotted navigation chain from `head`; `levels` > 0 makes the head an outer
|
|
289
|
-
// reference read exactly that many scopes out.
|
|
314
|
+
// reference read exactly that many scopes out. A bare head followed by `(` is
|
|
315
|
+
// a free-function call (`entries(x)`), which may then be navigated further.
|
|
290
316
|
private parseNavFrom(head: Token, levels = 0): { expr: Expr; name: string } {
|
|
291
317
|
let expr: Expr = levels > 0 ? { kind: "outer", levels, name: head.value } : { kind: "ident", name: head.value };
|
|
292
318
|
let name = head.value;
|
|
319
|
+
if (levels === 0 && this.at("lparen")) expr = { kind: "call", recv: null, name: head.value, args: this.parseArgs() };
|
|
293
320
|
while (this.at("dot")) {
|
|
294
321
|
this.next();
|
|
295
322
|
if (!this.at("ident")) this.fail("expected an identifier after '.' in a navigation");
|
|
@@ -333,7 +360,7 @@ class Parser {
|
|
|
333
360
|
const op = this.tryOp();
|
|
334
361
|
if (op) {
|
|
335
362
|
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
336
|
-
this.fail(`projection '${name}' must use collect/first/single, not ${op.op}`);
|
|
363
|
+
this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
|
|
337
364
|
}
|
|
338
365
|
if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${op.op} { … }`);
|
|
339
366
|
return { kind: "collect", name, op };
|
|
@@ -405,7 +432,7 @@ class Parser {
|
|
|
405
432
|
// Validate a consumer op used in where position and attach any `count { … } <op> N`.
|
|
406
433
|
private finishWhereOp(op: OpNode): OpNode {
|
|
407
434
|
if (op.op === "first" || op.op === "single") {
|
|
408
|
-
this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } or count { … } <op> N`);
|
|
435
|
+
this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } / none { … } or count { … } <op> N`);
|
|
409
436
|
}
|
|
410
437
|
if (op.op === "collect") {
|
|
411
438
|
const allLift = op.sub.select.length > 0 && op.sub.select.every((s) => s.kind === "field" && s.lift > 0);
|
|
@@ -457,7 +484,7 @@ class Parser {
|
|
|
457
484
|
private parseSubquery(): { sub: Subquery; distinct: boolean } {
|
|
458
485
|
const body = this.parseBody(true);
|
|
459
486
|
return {
|
|
460
|
-
sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values },
|
|
487
|
+
sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
|
|
461
488
|
distinct: body.distinct,
|
|
462
489
|
};
|
|
463
490
|
}
|
|
@@ -624,3 +651,9 @@ function navKey(e: Expr): string | null {
|
|
|
624
651
|
default: return null;
|
|
625
652
|
}
|
|
626
653
|
}
|
|
654
|
+
|
|
655
|
+
// The optional `limit`/`offset` fields of a Query/Subquery, present only when set
|
|
656
|
+
// (so a Query built without them is unchanged).
|
|
657
|
+
function bounds(b: { limit?: Expr | null; offset?: Expr | null }): { limit?: Expr; offset?: Expr } {
|
|
658
|
+
return { ...(b.limit ? { limit: b.limit } : {}), ...(b.offset ? { offset: b.offset } : {}) };
|
|
659
|
+
}
|
package/src/semantics.ts
CHANGED
|
@@ -167,9 +167,51 @@ export function parseRangeString(s: string): OqxRange | null {
|
|
|
167
167
|
return null;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// ---- entries: the explicit record → collection bridge -----------------------
|
|
171
|
+
//
|
|
172
|
+
// A plain object is NOT iterable in OQX (`from ${obj}` is one row). `entries(x)`
|
|
173
|
+
// converts it explicitly into a collection of ENTRY values, each `{ key, value }`
|
|
174
|
+
// tagged (non-enumerably, so JSON/deepEqual see a plain record) so the engine
|
|
175
|
+
// can recognize one: when an entry becomes a query scope, the scope's ROW is
|
|
176
|
+
// the property's value (`$value`, bare names) and `$key` is scope metadata.
|
|
177
|
+
// Arrays yield numeric index keys; a Map yields its entries; null/scalars yield
|
|
178
|
+
// nothing. (Arrays never expose an implicit `$key` — `entries(arr)` is how you
|
|
179
|
+
// ask for the index.)
|
|
180
|
+
|
|
181
|
+
const ENTRY: unique symbol = Symbol.for("oqx.entry");
|
|
182
|
+
|
|
183
|
+
export interface OqxEntry {
|
|
184
|
+
readonly key: unknown;
|
|
185
|
+
readonly value: unknown;
|
|
186
|
+
readonly [ENTRY]: true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Construct an entry value (see entriesOf). */
|
|
190
|
+
export function makeEntry(key: unknown, value: unknown): OqxEntry {
|
|
191
|
+
const e = { key, value } as { key: unknown; value: unknown; [ENTRY]?: true };
|
|
192
|
+
Object.defineProperty(e, ENTRY, { value: true, enumerable: false });
|
|
193
|
+
return e as OqxEntry;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether a value is an entry produced by `entries()` / `makeEntry`. */
|
|
197
|
+
export function isEntry(v: unknown): v is OqxEntry {
|
|
198
|
+
return typeof v === "object" && v !== null && (v as { [ENTRY]?: unknown })[ENTRY] === true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The entries of a host value: object → own enumerable (key, value) pairs in
|
|
202
|
+
* insertion order; array → (index, element); Map → its entries; anything else
|
|
203
|
+
* (absent, scalars, ranges) → none. */
|
|
204
|
+
export function entriesOf(v: unknown): OqxEntry[] {
|
|
205
|
+
if (v == null || typeof v !== "object" || isRange(v)) return [];
|
|
206
|
+
if (Array.isArray(v)) return v.map((x, i) => makeEntry(i, x));
|
|
207
|
+
if (v instanceof Map) return Array.from(v, ([k, x]) => makeEntry(k, x));
|
|
208
|
+
return Object.entries(v as Record<string, unknown>).map(([k, x]) => makeEntry(k, x));
|
|
209
|
+
}
|
|
210
|
+
|
|
170
211
|
/** Free functions callable as `name(args)`. */
|
|
171
212
|
export const BUILTIN_FUNCTIONS: Record<string, (args: unknown[]) => unknown> = {
|
|
172
213
|
list: (args) => toList(args[0]),
|
|
214
|
+
entries: (args) => entriesOf(args[0]),
|
|
173
215
|
size: (args) => sizeOf(args[0]),
|
|
174
216
|
has: (args) => args[0] != null,
|
|
175
217
|
// Coerce a string to a range value (or pass a range through); anything else,
|