@omgbase/oqx 0.7.0 → 0.9.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 +80 -6
- 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 +20 -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 -51
- 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 +95 -21
- package/dist/parser.js.map +1 -1
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +3 -1
- package/dist/plan.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/sqlite.ts +5 -3
- package/src/ast.ts +21 -3
- package/src/engine.ts +116 -44
- package/src/index.ts +3 -2
- package/src/parser.ts +87 -20
- package/src/plan.ts +3 -1
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 = "==" | "!=" | "<" | "<=" | ">" | ">=";
|
|
@@ -13,7 +14,7 @@ export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
|
|
|
13
14
|
/** Scalar value/predicate expression, evaluated against a row scope + bindings. */
|
|
14
15
|
export type Expr =
|
|
15
16
|
| { kind: "lit"; value: string | number | boolean | null }
|
|
16
|
-
| { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs)
|
|
17
|
+
| { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs); `$value` is the row itself
|
|
17
18
|
| { kind: "outer"; levels: number; name: string } // `^name` — read from exactly `levels` scopes out
|
|
18
19
|
| { kind: "binding"; index: number } // a ${…} interpolated host value
|
|
19
20
|
| { kind: "member"; recv: Expr; name: string } // .prop navigation on the value to its left
|
|
@@ -73,6 +74,18 @@ export interface Subquery {
|
|
|
73
74
|
select: SelectItem[];
|
|
74
75
|
orderBy: OrderSpec[] | null;
|
|
75
76
|
follow: Follow | null;
|
|
77
|
+
/** `values` — scalar projection mode: the (single) projected expression is the
|
|
78
|
+
* row's result itself rather than being wrapped in a `{ name: value }` record,
|
|
79
|
+
* so `name values` yields `["Bob", …]` and `$value values` yields the rows. */
|
|
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;
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
/** The where-clause boolean tree: OQX owns &&/||/!/grouping so consumer ops
|
|
@@ -95,4 +108,9 @@ export interface Query {
|
|
|
95
108
|
follow: Follow | null;
|
|
96
109
|
/** `distinct` — dedup the result rows by their projected value (see OpNode). */
|
|
97
110
|
distinct?: boolean;
|
|
111
|
+
/** `values` — scalar projection mode (see Subquery). */
|
|
112
|
+
values?: boolean;
|
|
113
|
+
/** `limit N` / `offset N` (see Subquery); evaluated at the root scope. */
|
|
114
|
+
limit?: Expr;
|
|
115
|
+
offset?: Expr;
|
|
98
116
|
}
|
package/src/engine.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// fall-through from an inner scope to an outer one — see `resolveIn`.
|
|
14
14
|
|
|
15
15
|
import type {
|
|
16
|
-
Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow,
|
|
16
|
+
Query, Where, Expr, OpNode, SelectItem, OrderSpec, Follow, Subquery,
|
|
17
17
|
} from "./ast.ts";
|
|
18
18
|
import type { DataContext } from "./context.ts";
|
|
19
19
|
import { DefaultContext } from "./context.ts";
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
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 };
|
|
@@ -51,6 +52,14 @@ interface Scope {
|
|
|
51
52
|
const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
|
|
52
53
|
const HARD_DEPTH_CAP = 8;
|
|
53
54
|
|
|
55
|
+
// What a scope projects to: the select list plus the `values` mode flag. Both
|
|
56
|
+
// `Query` and `Subquery` carry this shape.
|
|
57
|
+
type Projection = Pick<Subquery, "select" | "values">;
|
|
58
|
+
|
|
59
|
+
// An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
|
|
60
|
+
interface Bound { offset: number; limit: number | null; }
|
|
61
|
+
const UNBOUNDED: Bound = { offset: 0, limit: null };
|
|
62
|
+
|
|
54
63
|
export class InMemoryEngine implements Engine {
|
|
55
64
|
private ctx: DataContext;
|
|
56
65
|
|
|
@@ -65,22 +74,30 @@ export class InMemoryEngine implements Engine {
|
|
|
65
74
|
rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, root))));
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
|
|
77
|
+
const bound = this.boundOf(query, root);
|
|
78
|
+
if (query.follow) return this.runFollow(query, rows, root, bound);
|
|
69
79
|
|
|
70
80
|
// Consumer-directed short-circuits (skipped under `distinct`, which must
|
|
71
|
-
// materialize + dedup by projection before reducing).
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
// materialize + dedup by projection before reducing). Counting never
|
|
82
|
+
// materializes rows; exists/none stop as soon as the bound is known to be
|
|
83
|
+
// non-empty (the (offset+1)th match — or the first, when unbounded).
|
|
84
|
+
if (!query.distinct && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
|
|
85
|
+
const need = query.consumer === "count" ? Infinity : bound.offset + 1;
|
|
86
|
+
let n = 0;
|
|
87
|
+
for (const r of rows) {
|
|
88
|
+
if (this.matches(query.where, r, root)) { n++; if (n >= need) break; }
|
|
89
|
+
}
|
|
90
|
+
const m = boundedCount(n, bound);
|
|
91
|
+
if (query.consumer === "count") return { consumer: "count", count: m };
|
|
92
|
+
if (query.consumer === "exists") return { consumer: "exists", exists: m > 0 };
|
|
93
|
+
return { consumer: "none", none: m === 0 };
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
// first/single over an unordered, non-distinct set need only the rows up to
|
|
97
|
+
// the bound: offset + 1 (first) / offset + 2 (single, to detect a second).
|
|
98
|
+
const want = query.consumer === "first" ? 1 : query.consumer === "single" ? 2 : Infinity;
|
|
99
|
+
const cap = !query.orderBy && !query.distinct && want !== Infinity
|
|
100
|
+
? bound.offset + Math.min(want, bound.limit ?? want)
|
|
84
101
|
: Infinity;
|
|
85
102
|
let kept: Scope[] = [];
|
|
86
103
|
for (const r of rows) {
|
|
@@ -91,8 +108,28 @@ export class InMemoryEngine implements Engine {
|
|
|
91
108
|
}
|
|
92
109
|
}
|
|
93
110
|
this.sortScopes(kept, query.orderBy);
|
|
94
|
-
if (query.distinct) kept = this.dedupByProjection(kept, query
|
|
95
|
-
|
|
111
|
+
if (query.distinct) kept = this.dedupByProjection(kept, query);
|
|
112
|
+
kept = sliceBound(kept, bound);
|
|
113
|
+
return this.shape(query.consumer, kept, query);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
|
|
117
|
+
// is read in a row-less scope INSIDE it: a bare name is absent (there is no
|
|
118
|
+
// current item yet), `^name` is the enclosing row — exactly as in the block's
|
|
119
|
+
// body — and literals/bindings are themselves. For a top-level query `scope`
|
|
120
|
+
// is the root. Each must be a non-negative integer.
|
|
121
|
+
private boundOf(b: Pick<Subquery, "limit" | "offset">, enclosing: Scope): Bound {
|
|
122
|
+
if (!b.limit && !b.offset) return UNBOUNDED;
|
|
123
|
+
const scope: Scope = enclosing.parent === null ? enclosing : { row: undefined, parent: enclosing, bindings: enclosing.bindings };
|
|
124
|
+
const read = (e: Expr | undefined, word: string): number | null => {
|
|
125
|
+
if (!e) return null;
|
|
126
|
+
const v = this.evalExpr(e, scope);
|
|
127
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
128
|
+
throw new OqxError(`${word} must be a non-negative integer (got ${JSON.stringify(v ?? null)})`, "eval");
|
|
129
|
+
}
|
|
130
|
+
return v;
|
|
131
|
+
};
|
|
132
|
+
return { offset: read(b.offset, "offset") ?? 0, limit: read(b.limit, "limit") };
|
|
96
133
|
}
|
|
97
134
|
|
|
98
135
|
// A where match that needs no lift capture (exists/count fast paths).
|
|
@@ -111,27 +148,28 @@ export class InMemoryEngine implements Engine {
|
|
|
111
148
|
|
|
112
149
|
// ---- follow ---------------------------------------------------------------
|
|
113
150
|
|
|
114
|
-
private runFollow(query: Query, rows: unknown[], root: Scope): OqxResult {
|
|
151
|
+
private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
|
|
115
152
|
const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
|
|
116
153
|
const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
|
|
117
154
|
const occ = this.followWalk(seeds, query.follow!, root);
|
|
118
155
|
let scopes: Scope[] = occ.map((o) => ({ row: o.row, parent: root, bindings: root.bindings, lifts: {}, meta: o.meta }));
|
|
119
156
|
if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
|
|
120
157
|
this.sortScopes(scopes, query.orderBy);
|
|
121
|
-
if (query.distinct) scopes = this.dedupByProjection(scopes, query
|
|
122
|
-
|
|
158
|
+
if (query.distinct) scopes = this.dedupByProjection(scopes, query);
|
|
159
|
+
scopes = sliceBound(scopes, bound);
|
|
160
|
+
return this.shape(query.consumer, scopes, query);
|
|
123
161
|
}
|
|
124
162
|
|
|
125
163
|
// Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
|
|
126
164
|
// distinct projection, preserving order. An empty projection dedups by row
|
|
127
165
|
// identity (so `count distinct { }` counts distinct rows).
|
|
128
|
-
private dedupByProjection(scopes: Scope[],
|
|
166
|
+
private dedupByProjection(scopes: Scope[], proj: Projection): Scope[] {
|
|
129
167
|
const seen = new Set<string>();
|
|
130
168
|
const out: Scope[] = [];
|
|
131
169
|
for (const s of scopes) {
|
|
132
|
-
const key = select.length === 0
|
|
170
|
+
const key = proj.select.length === 0
|
|
133
171
|
? `i:${String(this.ctx.identity(s.row))}`
|
|
134
|
-
: `p:${stableStringify(this.projectRow(
|
|
172
|
+
: `p:${stableStringify(this.projectRow(proj, s))}`;
|
|
135
173
|
if (seen.has(key)) continue;
|
|
136
174
|
seen.add(key);
|
|
137
175
|
out.push(s);
|
|
@@ -213,27 +251,34 @@ export class InMemoryEngine implements Engine {
|
|
|
213
251
|
|
|
214
252
|
// ---- consumer shaping -----------------------------------------------------
|
|
215
253
|
|
|
216
|
-
private shape(consumer: Query["consumer"], scopes: Scope[],
|
|
254
|
+
private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
|
|
217
255
|
switch (consumer) {
|
|
218
256
|
case "exists": return { consumer, exists: scopes.length > 0 };
|
|
257
|
+
case "none": return { consumer, none: scopes.length === 0 };
|
|
219
258
|
case "count": return { consumer, count: scopes.length };
|
|
220
|
-
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(
|
|
221
|
-
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(
|
|
259
|
+
case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
|
|
260
|
+
case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
|
|
222
261
|
case "single":
|
|
223
262
|
if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
|
|
224
|
-
return { consumer, row: scopes.length > 0 ? this.projectRow(
|
|
263
|
+
return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
|
|
225
264
|
}
|
|
226
265
|
}
|
|
227
266
|
|
|
228
|
-
|
|
267
|
+
// The per-row result: the raw row (empty projection), the single item's value
|
|
268
|
+
// itself (`values` mode), or a `{ name: value }` record.
|
|
269
|
+
private projectRow(proj: Projection, scope: Scope): unknown {
|
|
270
|
+
const { select } = proj;
|
|
229
271
|
if (select.length === 0) return scope.row;
|
|
272
|
+
if (proj.values) return this.itemValue(select[0]!, scope);
|
|
230
273
|
const out: Record<string, unknown> = {};
|
|
231
|
-
for (const item of select)
|
|
232
|
-
out[item.name] = item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
|
|
233
|
-
}
|
|
274
|
+
for (const item of select) out[item.name] = this.itemValue(item, scope);
|
|
234
275
|
return out;
|
|
235
276
|
}
|
|
236
277
|
|
|
278
|
+
private itemValue(item: SelectItem, scope: Scope): unknown {
|
|
279
|
+
return item.kind === "field" ? this.evalExpr(item.expr, scope) : this.evalCollectValue(item.op, scope);
|
|
280
|
+
}
|
|
281
|
+
|
|
237
282
|
// ---- where evaluation -----------------------------------------------------
|
|
238
283
|
|
|
239
284
|
private evalWhere(w: Where, scope: Scope): boolean {
|
|
@@ -252,9 +297,15 @@ export class InMemoryEngine implements Engine {
|
|
|
252
297
|
|
|
253
298
|
private evalWhereOp(op: OpNode, scope: Scope): boolean {
|
|
254
299
|
if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
|
|
255
|
-
|
|
300
|
+
const bound = this.boundOf(op.sub, scope);
|
|
301
|
+
if (op.op === "exists" || op.op === "none") {
|
|
302
|
+
// Unbounded: stop at the first match (dedup cannot change emptiness).
|
|
303
|
+
// Bounded: the offset/limit decide emptiness, so materialize the set.
|
|
304
|
+
const any = bound === UNBOUNDED ? this.anyMatch(op, scope) : this.opRows(op, scope, bound).length > 0;
|
|
305
|
+
return op.op === "exists" ? any : !any;
|
|
306
|
+
}
|
|
256
307
|
if (op.op === "collect") {
|
|
257
|
-
const matched = this.
|
|
308
|
+
const matched = this.opRows(op, scope, bound);
|
|
258
309
|
for (const item of op.sub.select) {
|
|
259
310
|
if (item.kind !== "field") continue;
|
|
260
311
|
// Bind `item.lift` scopes out: `^` = the collect's own scope, `^^` its
|
|
@@ -270,12 +321,18 @@ export class InMemoryEngine implements Engine {
|
|
|
270
321
|
return matched.length > 0;
|
|
271
322
|
}
|
|
272
323
|
if (op.op === "count") {
|
|
273
|
-
|
|
274
|
-
if (op.distinct) rows = this.dedupByProjection(rows, op.sub.select);
|
|
275
|
-
const n = rows.length;
|
|
324
|
+
const n = this.opRows(op, scope, bound).length;
|
|
276
325
|
return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
|
|
277
326
|
}
|
|
278
|
-
return this.
|
|
327
|
+
return this.opRows(op, scope, bound).length > 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The rows a consumer op reduces: matched → ordered → distinct → bounded.
|
|
331
|
+
private opRows(op: OpNode, scope: Scope, bound: Bound): Scope[] {
|
|
332
|
+
let scopes = this.matchRows(op, scope);
|
|
333
|
+
this.sortScopes(scopes, op.sub.orderBy);
|
|
334
|
+
if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
|
|
335
|
+
return sliceBound(scopes, bound);
|
|
279
336
|
}
|
|
280
337
|
|
|
281
338
|
// Short-circuiting existence check over a consumer receiver.
|
|
@@ -300,23 +357,25 @@ export class InMemoryEngine implements Engine {
|
|
|
300
357
|
// A select-position collect/first/single, optionally recursive via `follow`.
|
|
301
358
|
private evalCollectValue(op: OpNode, scope: Scope): unknown {
|
|
302
359
|
const sub = op.sub;
|
|
360
|
+
const bound = this.boundOf(sub, scope);
|
|
303
361
|
let scopes: Scope[];
|
|
304
362
|
if (sub.follow) {
|
|
305
363
|
let rows = this.rowsOf(this.evalExpr(op.receiver, scope));
|
|
306
364
|
for (const proj of sub.from) rows = rows.flatMap((r) => this.rowsOf(this.evalExpr(proj, this.child(r, scope))));
|
|
307
365
|
const seeds = sub.where ? rows.filter((r) => this.evalWhere(sub.where!, { row: r, parent: scope, bindings: scope.bindings, lifts: {} })) : rows;
|
|
308
366
|
scopes = this.followWalk(seeds, sub.follow, scope).map((o) => ({ row: o.row, parent: scope, bindings: scope.bindings, meta: o.meta }));
|
|
367
|
+
this.sortScopes(scopes, sub.orderBy);
|
|
368
|
+
if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
|
|
369
|
+
scopes = sliceBound(scopes, bound);
|
|
309
370
|
} else {
|
|
310
|
-
scopes = this.
|
|
371
|
+
scopes = this.opRows(op, scope, bound);
|
|
311
372
|
}
|
|
312
|
-
this.sortScopes(scopes, sub.orderBy);
|
|
313
|
-
if (op.distinct) scopes = this.dedupByProjection(scopes, sub.select);
|
|
314
373
|
switch (op.op) {
|
|
315
|
-
case "collect": return scopes.map((s) => this.projectRow(sub
|
|
316
|
-
case "first": return scopes.length > 0 ? this.projectRow(sub
|
|
374
|
+
case "collect": return scopes.map((s) => this.projectRow(sub, s));
|
|
375
|
+
case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
|
|
317
376
|
case "single":
|
|
318
377
|
if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
|
|
319
|
-
return scopes.length > 0 ? this.projectRow(sub
|
|
378
|
+
return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
|
|
320
379
|
default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
|
|
321
380
|
}
|
|
322
381
|
}
|
|
@@ -390,9 +449,11 @@ export class InMemoryEngine implements Engine {
|
|
|
390
449
|
}
|
|
391
450
|
|
|
392
451
|
// Resolve a name against ONE scope — never its ancestors. A scope provides,
|
|
393
|
-
// in order: the
|
|
394
|
-
//
|
|
395
|
-
//
|
|
452
|
+
// in order: `$value` (the scope's row itself — the current item, whatever its
|
|
453
|
+
// type, so scalar collections are queryable; absent at the root, which has no
|
|
454
|
+
// row); the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
|
|
455
|
+
// values lifted into it by `^name:` items; then either the row's own property
|
|
456
|
+
// or, for the root scope (no row), the context's named roots.
|
|
396
457
|
//
|
|
397
458
|
// A name the scope lacks is simply absent (undefined). It does NOT fall
|
|
398
459
|
// through to an enclosing scope, so a query's meaning never depends on which
|
|
@@ -401,6 +462,7 @@ export class InMemoryEngine implements Engine {
|
|
|
401
462
|
// always spelled explicitly as `^name`. Present-but-falsy values (null, false,
|
|
402
463
|
// 0, "") need no special case — there is no "absent, so look outward" rule.
|
|
403
464
|
private resolveIn(name: string, scope: Scope): unknown {
|
|
465
|
+
if (name === "$value") return scope.parent === null ? undefined : scope.row;
|
|
404
466
|
if (RECUR.has(name)) return scope.meta ? scope.meta[name] : undefined;
|
|
405
467
|
if (scope.lifts && name in scope.lifts) return scope.lifts[name];
|
|
406
468
|
if (scope.parent === null) return this.ctx.root(name);
|
|
@@ -446,6 +508,16 @@ function compareCount(n: number, cmp: { op: string; value: number }): boolean {
|
|
|
446
508
|
return relate(cmp.op, n, cmp.value);
|
|
447
509
|
}
|
|
448
510
|
|
|
511
|
+
// Apply a bound to an ordered row set / to a match count.
|
|
512
|
+
function sliceBound<T>(rows: T[], b: Bound): T[] {
|
|
513
|
+
if (b === UNBOUNDED) return rows;
|
|
514
|
+
return rows.slice(b.offset, b.limit == null ? undefined : b.offset + b.limit);
|
|
515
|
+
}
|
|
516
|
+
function boundedCount(n: number, b: Bound): number {
|
|
517
|
+
const rest = Math.max(0, n - b.offset);
|
|
518
|
+
return b.limit == null ? rest : Math.min(rest, b.limit);
|
|
519
|
+
}
|
|
520
|
+
|
|
449
521
|
// Order `&&` conjuncts so cheap scalar leaves run before consumer-op leaves.
|
|
450
522
|
function orderByCost(parts: Where[]): Where[] {
|
|
451
523
|
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",
|
|
28
|
-
"order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in",
|
|
27
|
+
"collect", "exists", "none", "count", "first", "single",
|
|
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>(["==", "!=", "<", "<=", ">", ">="]);
|
|
@@ -49,6 +50,9 @@ interface BodyClauses {
|
|
|
49
50
|
orderBy: OrderSpec[] | null;
|
|
50
51
|
follow: Follow | null;
|
|
51
52
|
distinct: boolean;
|
|
53
|
+
values: boolean;
|
|
54
|
+
limit: Expr | null;
|
|
55
|
+
offset: Expr | null;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
class Parser {
|
|
@@ -89,6 +93,8 @@ class Parser {
|
|
|
89
93
|
consumer: directive.op,
|
|
90
94
|
follow: directive.sub.follow,
|
|
91
95
|
distinct: directive.distinct ?? false,
|
|
96
|
+
values: directive.sub.values ?? false,
|
|
97
|
+
...bounds(directive.sub),
|
|
92
98
|
};
|
|
93
99
|
}
|
|
94
100
|
if (directive) this.fail(`unexpected ${this.tokDesc()} after the top-level directive`);
|
|
@@ -108,6 +114,8 @@ class Parser {
|
|
|
108
114
|
consumer: "collect",
|
|
109
115
|
follow: body.follow,
|
|
110
116
|
distinct: body.distinct,
|
|
117
|
+
values: body.values,
|
|
118
|
+
...bounds(body),
|
|
111
119
|
};
|
|
112
120
|
}
|
|
113
121
|
|
|
@@ -124,6 +132,9 @@ class Parser {
|
|
|
124
132
|
let orderBy: OrderSpec[] | null = null;
|
|
125
133
|
let follow: Follow | null = null;
|
|
126
134
|
let distinct = false;
|
|
135
|
+
let values = false;
|
|
136
|
+
let limit: Expr | null = null;
|
|
137
|
+
let offset: Expr | null = null;
|
|
127
138
|
let sawWhere = false, sawSelect = false, sawOrder = false;
|
|
128
139
|
|
|
129
140
|
while (!this.at("eof") && !this.at("rbrace")) {
|
|
@@ -148,7 +159,14 @@ class Parser {
|
|
|
148
159
|
if (sawSelect) this.fail("duplicate projection");
|
|
149
160
|
sawSelect = true;
|
|
150
161
|
if (this.at("kw")) { this.next(); if (this.at("ident", "distinct")) { this.next(); distinct = true; } } // consume `select` + optional `distinct`; a leading `^` is part of the item
|
|
151
|
-
select = this.
|
|
162
|
+
({ items: select, values } = this.parseProjection());
|
|
163
|
+
continue;
|
|
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; }
|
|
152
170
|
continue;
|
|
153
171
|
}
|
|
154
172
|
if (orderByAllowed && this.atOrderBy()) {
|
|
@@ -167,12 +185,22 @@ class Parser {
|
|
|
167
185
|
if (this.at("ident") || this.at("binding")) {
|
|
168
186
|
if (sawSelect) this.fail("duplicate projection (an implicit select cannot follow a `select`)");
|
|
169
187
|
sawSelect = true;
|
|
170
|
-
select = this.
|
|
188
|
+
({ items: select, values } = this.parseProjection());
|
|
171
189
|
continue;
|
|
172
190
|
}
|
|
173
|
-
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`);
|
|
174
192
|
}
|
|
175
|
-
return { froms, where, select, orderBy, follow, distinct };
|
|
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");
|
|
176
204
|
}
|
|
177
205
|
|
|
178
206
|
// Decide, by syntactic shape only, whether a leading unkeyworded run is a
|
|
@@ -296,24 +324,40 @@ class Parser {
|
|
|
296
324
|
}
|
|
297
325
|
|
|
298
326
|
// ---- select ---------------------------------------------------------------
|
|
299
|
-
|
|
327
|
+
// A projection list, optionally followed by the `values` mode word. Under
|
|
328
|
+
// `values` the list must be exactly one item, which need not be named: the
|
|
329
|
+
// row's result IS that value (no `{ name: value }` record), so a name would be
|
|
330
|
+
// meaningless. Without `values`, every item needs a key — a bare/dotted
|
|
331
|
+
// navigation supplies its own (the last segment); any other expression must be
|
|
332
|
+
// aliased (`name: expr`).
|
|
333
|
+
private parseProjection(): { items: SelectItem[]; values: boolean } {
|
|
300
334
|
const items = [this.parseSelectItem()];
|
|
301
335
|
while (this.at("comma")) { this.next(); items.push(this.parseSelectItem()); }
|
|
302
|
-
|
|
336
|
+
let values = false;
|
|
337
|
+
if (this.at("ident", "values")) {
|
|
338
|
+
this.next();
|
|
339
|
+
values = true;
|
|
340
|
+
if (items.length !== 1) this.fail("`values` projects exactly one expression (got " + items.length + ")");
|
|
341
|
+
const only = items[0]!;
|
|
342
|
+
if (only.kind === "field" && only.lift > 0) this.fail("a lift (^name: …) cannot be combined with `values`");
|
|
343
|
+
} else {
|
|
344
|
+
for (const it of items) {
|
|
345
|
+
if (it.name === "") this.fail("a projection item that is not a plain name needs an alias (`name: expr`) unless it is followed by `values`");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { items, values };
|
|
303
349
|
}
|
|
304
350
|
|
|
305
351
|
private parseSelectItem(): SelectItem {
|
|
306
352
|
// Leading `^`s mark a lift; the count is how many scopes out it binds.
|
|
307
353
|
const lift = this.parseCarets();
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
this.next();
|
|
312
|
-
const name = nameTok.value;
|
|
354
|
+
if (this.at("ident") && this.peekAt(1)?.type === "colon") {
|
|
355
|
+
const name = this.next().value;
|
|
356
|
+
this.next(); // ':'
|
|
313
357
|
const op = this.tryOp();
|
|
314
358
|
if (op) {
|
|
315
359
|
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
316
|
-
this.fail(`projection '${name}' must use collect/first/single, not ${op.op}`);
|
|
360
|
+
this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none/count are where-position tests)`);
|
|
317
361
|
}
|
|
318
362
|
if (lift) this.fail(`a lift (^${name}) value must be a scalar expression, not ${op.op} { … }`);
|
|
319
363
|
return { kind: "collect", name, op };
|
|
@@ -321,9 +365,12 @@ class Parser {
|
|
|
321
365
|
const expr = this.parseValueExpr();
|
|
322
366
|
return { kind: "field", name, expr, lift };
|
|
323
367
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
368
|
+
if (!this.at("ident") && !this.canStartValue()) this.fail("expected a projection name");
|
|
369
|
+
// Unaliased item: a bare/dotted navigation keys by its last segment; any
|
|
370
|
+
// other expression is unnamed ("") — legal only under `values` (checked by
|
|
371
|
+
// parseProjection, which sees the whole list).
|
|
372
|
+
const expr = this.parseValueExpr();
|
|
373
|
+
return { kind: "field", name: navKey(expr) ?? "", expr, lift };
|
|
327
374
|
}
|
|
328
375
|
|
|
329
376
|
// ---- order by -------------------------------------------------------------
|
|
@@ -382,7 +429,7 @@ class Parser {
|
|
|
382
429
|
// Validate a consumer op used in where position and attach any `count { … } <op> N`.
|
|
383
430
|
private finishWhereOp(op: OpNode): OpNode {
|
|
384
431
|
if (op.op === "first" || op.op === "single") {
|
|
385
|
-
this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } or count { … } <op> N`);
|
|
432
|
+
this.fail(`${op.op} { … } is a select-position lookup; in where use exists { … } / none { … } or count { … } <op> N`);
|
|
386
433
|
}
|
|
387
434
|
if (op.op === "collect") {
|
|
388
435
|
const allLift = op.sub.select.length > 0 && op.sub.select.every((s) => s.kind === "field" && s.lift > 0);
|
|
@@ -433,7 +480,10 @@ class Parser {
|
|
|
433
480
|
|
|
434
481
|
private parseSubquery(): { sub: Subquery; distinct: boolean } {
|
|
435
482
|
const body = this.parseBody(true);
|
|
436
|
-
return {
|
|
483
|
+
return {
|
|
484
|
+
sub: { from: body.froms, where: body.where, select: body.select, orderBy: body.orderBy, follow: body.follow, values: body.values, ...bounds(body) },
|
|
485
|
+
distinct: body.distinct,
|
|
486
|
+
};
|
|
437
487
|
}
|
|
438
488
|
|
|
439
489
|
// ---- expression Pratt parser ----------------------------------------------
|
|
@@ -587,3 +637,20 @@ class Parser {
|
|
|
587
637
|
this.fail(`unexpected ${this.tokDesc()} — expected a value`);
|
|
588
638
|
}
|
|
589
639
|
}
|
|
640
|
+
|
|
641
|
+
// The default key of an unaliased projection item: the last segment of a bare /
|
|
642
|
+
// dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
|
|
643
|
+
function navKey(e: Expr): string | null {
|
|
644
|
+
switch (e.kind) {
|
|
645
|
+
case "ident": return e.name;
|
|
646
|
+
case "outer": return e.name;
|
|
647
|
+
case "member": return e.name;
|
|
648
|
+
default: return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// The optional `limit`/`offset` fields of a Query/Subquery, present only when set
|
|
653
|
+
// (so a Query built without them is unchanged).
|
|
654
|
+
function bounds(b: { limit?: Expr | null; offset?: Expr | null }): { limit?: Expr; offset?: Expr } {
|
|
655
|
+
return { ...(b.limit ? { limit: b.limit } : {}), ...(b.offset ? { offset: b.offset } : {}) };
|
|
656
|
+
}
|
package/src/plan.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
// Because a bare identifier resolves against the current row ONLY (it never
|
|
13
13
|
// climbs to an enclosing scope or a named root), an `ident` in a top-level
|
|
14
14
|
// `where` is unambiguously a column of the scanned rows and is safe to push. An
|
|
15
|
-
// `outer` (`^name`) reference is not a row column and stays residual
|
|
15
|
+
// `outer` (`^name`) reference is not a row column and stays residual, and so is
|
|
16
|
+
// the `$value` intrinsic (the row itself, not one of its columns) — adapters
|
|
17
|
+
// gate idents on their known column set, which never includes it.
|
|
16
18
|
|
|
17
19
|
import type { Query, Where, Expr } from "./ast.ts";
|
|
18
20
|
|