@omgbase/oqx 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +149 -64
- package/dist/ast.d.ts +21 -5
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +1 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +7 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +7 -3
- package/dist/build.js.map +1 -1
- package/dist/context.d.ts +19 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +6 -9
- package/dist/context.js.map +1 -1
- package/dist/engine.d.ts +12 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +235 -83
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/optimize/analysis.d.ts +3 -2
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +14 -15
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +196 -114
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +13 -13
- package/dist/print.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +11 -8
- package/dist/resolve.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts +5 -3
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +26 -8
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +23 -6
- package/src/build.ts +8 -7
- package/src/context.ts +22 -11
- package/src/engine.ts +236 -90
- package/src/index.ts +2 -1
- package/src/optimize/analysis.ts +13 -12
- package/src/parser.ts +180 -96
- package/src/print.ts +12 -10
- package/src/resolve.ts +11 -7
- package/src/version.ts +1 -1
- package/src/walk.ts +32 -11
package/src/optimize/analysis.ts
CHANGED
|
@@ -34,6 +34,7 @@ export function exprReadsScopeIn(e: Expr, at: number, upTo: number): boolean {
|
|
|
34
34
|
case "op": return opReadsScopeIn(e, at, upTo);
|
|
35
35
|
case "binary": case "logical": case "in": return exprReadsScopeIn(e.left, at, upTo) || exprReadsScopeIn(e.right, at, upTo);
|
|
36
36
|
case "range": return (e.lo !== null && exprReadsScopeIn(e.lo, at, upTo)) || (e.hi !== null && exprReadsScopeIn(e.hi, at, upTo));
|
|
37
|
+
case "tuple": return e.items.some((x) => exprReadsScopeIn(x, at, upTo));
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -47,22 +48,26 @@ export function whereReadsScopeIn(w: Where, at: number, upTo: number): boolean {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
/** A directive evaluated in a scope at depth `at`: its receiver is read there;
|
|
50
|
-
* its rows are scopes at `at + 1`.
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
* its rows are scopes at `at + 1`. `destination` marks a follow destination
|
|
52
|
+
* block (its `frontier` is read one level out). */
|
|
53
|
+
export function opReadsScopeIn(op: OpNode, at: number, upTo: number, destination = false): boolean {
|
|
54
|
+
return exprReadsScopeIn(op.receiver, at, upTo) || subReadsScopeIn(op.sub, at + 1, upTo, destination);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** A block body whose rows are scopes at depth `at`. `from` re-projections,
|
|
56
58
|
* `where`, `select`, `order by` and the bound are read in the row scope (the
|
|
57
|
-
* bound in a row-less scope at the same depth);
|
|
58
|
-
* `
|
|
59
|
-
|
|
59
|
+
* bound in a row-less scope at the same depth); a destination block's
|
|
60
|
+
* `frontier` is read one level out, under the walk's enclosing scope (§20),
|
|
61
|
+
* any other block's `frontier` (its seeds') in the row scope like `where`;
|
|
62
|
+
* `follow` walks occurrences at `at`. */
|
|
63
|
+
function subReadsScopeIn(sub: Subquery, at: number, upTo: number, destination: boolean): boolean {
|
|
60
64
|
if (sub.from.some((e) => exprReadsScopeIn(e, at, upTo))) return true;
|
|
61
65
|
if (sub.where && whereReadsScopeIn(sub.where, at, upTo)) return true;
|
|
62
66
|
if (sub.select.some((item) => itemReadsScopeIn(item, at, upTo))) return true;
|
|
63
67
|
if (sub.orderBy?.some((o) => exprReadsScopeIn(o.expr, at, upTo))) return true;
|
|
64
68
|
if (sub.limit && exprReadsScopeIn(sub.limit, at, upTo)) return true;
|
|
65
69
|
if (sub.offset && exprReadsScopeIn(sub.offset, at, upTo)) return true;
|
|
70
|
+
if (sub.frontier && exprReadsScopeIn(sub.frontier, destination ? at - 1 : at, upTo)) return true;
|
|
66
71
|
return sub.follow !== null && followReadsScopeIn(sub.follow, at, upTo);
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -71,12 +76,7 @@ function itemReadsScopeIn(item: SelectItem, at: number, upTo: number): boolean {
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
function followReadsScopeIn(f: Follow, at: number, upTo: number): boolean {
|
|
74
|
-
|
|
75
|
-
if (d.kind === "op" ? opReadsScopeIn(d, at, upTo) : exprReadsScopeIn(d, at, upTo)) return true;
|
|
76
|
-
}
|
|
77
|
-
if (f.where && exprReadsScopeIn(f.where, at + 1, upTo)) return true;
|
|
78
|
-
if (f.frontier && exprReadsScopeIn(f.frontier, at, upTo)) return true;
|
|
79
|
-
return f.by !== null && exprReadsScopeIn(f.by, at, upTo);
|
|
79
|
+
return f.destinations.some((d) => (d.kind === "op" ? opReadsScopeIn(d, at, upTo, true) : exprReadsScopeIn(d, at, upTo)));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/** Does `e` read the scope it is evaluated in (a bare identifier anywhere)? */
|
|
@@ -100,6 +100,7 @@ export function exprRaiseFree(e: Expr, ctx: RuleContext): boolean {
|
|
|
100
100
|
case "unary": return exprRaiseFree(e.expr, ctx);
|
|
101
101
|
case "binary": case "logical": case "in": return exprRaiseFree(e.left, ctx) && exprRaiseFree(e.right, ctx);
|
|
102
102
|
case "range": return (e.lo === null || exprRaiseFree(e.lo, ctx)) && (e.hi === null || exprRaiseFree(e.hi, ctx));
|
|
103
|
+
case "tuple": return e.items.every((x) => exprRaiseFree(x, ctx));
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
|
package/src/parser.ts
CHANGED
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
//
|
|
18
18
|
// A `follow` destination is a relation of the current row (a receiver, never
|
|
19
19
|
// an outer reference) or a destination block — a receiver (which MAY be an
|
|
20
|
-
// outer reference, `^people`)
|
|
21
|
-
// `single` [`distinct`] `{ body }`, exactly the
|
|
22
|
-
// `tryOp
|
|
20
|
+
// outer reference, `^people`, or a tuple `(a, b)`) followed by `{ body }`, or
|
|
21
|
+
// by `collect`/`first`/`single` [`distinct`] `{ body }`, exactly the
|
|
22
|
+
// select-position directive `tryOp` recognizes, re-evaluated per frontier row.
|
|
23
|
+
// Since 0.20 there is no options block: a brace after ANY destination opens
|
|
24
|
+
// that destination's block, whose body admits two clauses no other block has —
|
|
25
|
+
// `frontier P` and `depth n`, in the clause order after `where`.
|
|
23
26
|
//
|
|
24
27
|
// Every clause is optional except that a top-level body needs `from` (the
|
|
25
28
|
// receiver-plus-consumer form `<receiver> <consumer> { block }` supplies the
|
|
@@ -33,8 +36,9 @@
|
|
|
33
36
|
// out-of-order clause is a parse error naming the order.
|
|
34
37
|
//
|
|
35
38
|
// Sugar (0.17) desugars HERE, to existing nodes: `x { … }` is `x collect { … }`
|
|
36
|
-
//
|
|
37
|
-
//
|
|
39
|
+
// wherever a value stands (a select item, a where leaf, a follow destination, a
|
|
40
|
+
// call argument — since 0.20 without exception) and `x distinct { … }` is
|
|
41
|
+
// `x collect distinct { … }` (0.20); `x[p]` is `x first { where p }`, `x[n]` is `x first { offset n }`, with a
|
|
38
42
|
// trailing `!` making the first a `single` (predicate) or a `required` (index);
|
|
39
43
|
// `is x` is `!!x`, `not x` is `!x`. Two shapes are new: the postfix `required`
|
|
40
44
|
// (`x!`, tightest) and the identity operators `is` / `is not` (comparison
|
|
@@ -57,6 +61,9 @@
|
|
|
57
61
|
// source, from the first token that produced it to the end of the last; a
|
|
58
62
|
// parenthesized operand's span includes its parentheses (spec/oqx/AST.md).
|
|
59
63
|
//
|
|
64
|
+
// Tuples (0.20): `(e1, e2, …)` with two or more elements is a `tuple` node — a
|
|
65
|
+
// value and a receiver; one expression in parentheses stays grouping.
|
|
66
|
+
//
|
|
60
67
|
// Principle of least surprise, applied to the grammar: a rule a careful user
|
|
61
68
|
// would not predict is a bug. Hence `!` binds tighter than comparison in every
|
|
62
69
|
// position (`!a == b` is `(!a) == b`, as in C); parentheses in `where` group a
|
|
@@ -78,8 +85,17 @@ import { printTemplate } from "./print.ts";
|
|
|
78
85
|
|
|
79
86
|
const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
|
|
80
87
|
// The fixed clause order of a body (ADR-020). Each clause appears at most once.
|
|
81
|
-
|
|
88
|
+
// `frontier` is a clause of every body that has a `follow` (a destination
|
|
89
|
+
// block's marks the occurrences it reaches, any other body's its seeds — 0.20);
|
|
90
|
+
// `depth` is legal only in a follow destination block, and the order sentence
|
|
91
|
+
// names it only there.
|
|
92
|
+
const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "order by", "limit", "offset"] as const;
|
|
82
93
|
type Clause = (typeof CLAUSE_ORDER)[number];
|
|
94
|
+
const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
|
|
95
|
+
// The clauses a body may hold, in order.
|
|
96
|
+
function clausesOf(ctx: BodyCtx): Clause[] {
|
|
97
|
+
return CLAUSE_ORDER.filter((c) => ctx.destination || !DESTINATION_ONLY.has(c));
|
|
98
|
+
}
|
|
83
99
|
// Contextual clause words lexed as bare idents; an open-ended range must stop
|
|
84
100
|
// before them rather than consume them as its high bound.
|
|
85
101
|
const CLAUSE_WORDS = new Set<string>([
|
|
@@ -103,8 +119,12 @@ interface BodyCtx {
|
|
|
103
119
|
op: Consumer | null;
|
|
104
120
|
/** Whether `^name: expr` lift items are legal: only in a where-position `collect { … }`. */
|
|
105
121
|
liftsAllowed: boolean;
|
|
122
|
+
/** A `follow` destination block (0.20): the only body that admits `depth`, and
|
|
123
|
+
* the one whose `frontier` marks the occurrences it reaches (read one level out)
|
|
124
|
+
* rather than the seeds of its own `follow`. */
|
|
125
|
+
destination: boolean;
|
|
106
126
|
}
|
|
107
|
-
const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false };
|
|
127
|
+
const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false, destination: false };
|
|
108
128
|
|
|
109
129
|
/** Parse a tagged-template call into a Query. */
|
|
110
130
|
export function parseTemplate(fragments: readonly string[], values: number): Query {
|
|
@@ -122,6 +142,8 @@ interface BodyClauses {
|
|
|
122
142
|
select: SelectItem[];
|
|
123
143
|
orderBy: OrderSpec[] | null;
|
|
124
144
|
follow: Follow | null;
|
|
145
|
+
frontier: Expr | null;
|
|
146
|
+
depth: number | null;
|
|
125
147
|
distinct: boolean;
|
|
126
148
|
values: boolean;
|
|
127
149
|
limit: Expr | null;
|
|
@@ -231,6 +253,7 @@ class Parser {
|
|
|
231
253
|
orderBy: body.orderBy,
|
|
232
254
|
consumer: "collect",
|
|
233
255
|
follow: body.follow,
|
|
256
|
+
frontier: body.frontier,
|
|
234
257
|
distinct: body.distinct,
|
|
235
258
|
values: body.values,
|
|
236
259
|
limit: body.limit,
|
|
@@ -243,7 +266,7 @@ class Parser {
|
|
|
243
266
|
private queryFromOp(e: OpNode, start: number): Query {
|
|
244
267
|
return {
|
|
245
268
|
kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
|
|
246
|
-
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, distinct: e.distinct, values: e.sub.values,
|
|
269
|
+
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
|
|
247
270
|
limit: e.sub.limit, offset: e.sub.offset,
|
|
248
271
|
};
|
|
249
272
|
}
|
|
@@ -264,6 +287,8 @@ class Parser {
|
|
|
264
287
|
let select: SelectItem[] = [];
|
|
265
288
|
let orderBy: OrderSpec[] | null = null;
|
|
266
289
|
let follow: Follow | null = null;
|
|
290
|
+
let frontier: Expr | null = null;
|
|
291
|
+
let depth: number | null = null;
|
|
267
292
|
let distinct = false;
|
|
268
293
|
let values = false;
|
|
269
294
|
let limit: Expr | null = null;
|
|
@@ -278,7 +303,7 @@ class Parser {
|
|
|
278
303
|
const idx = CLAUSE_ORDER.indexOf(clause);
|
|
279
304
|
if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
|
|
280
305
|
if (idx < stage) {
|
|
281
|
-
this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${
|
|
306
|
+
this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
|
|
282
307
|
}
|
|
283
308
|
stage = idx;
|
|
284
309
|
};
|
|
@@ -303,6 +328,27 @@ class Parser {
|
|
|
303
328
|
where = this.atDepth(rowDepth, () => this.parseWhere());
|
|
304
329
|
continue;
|
|
305
330
|
}
|
|
331
|
+
// `frontier P` (0.20) — in a follow destination block the destination's
|
|
332
|
+
// own clause, read in the occurrence's scope whose parent is the walk's
|
|
333
|
+
// enclosing scope: one level OUT of the block's rows (SEMANTICS §20), so
|
|
334
|
+
// `^` there is the enclosing scope, not the frontier row. In any other
|
|
335
|
+
// body it marks the seeds of the body's own `follow` (checked below) and
|
|
336
|
+
// is read in the seed's scope, like `where`.
|
|
337
|
+
if (this.at("ident", "frontier") && this.startsValue(this.peekAt(1))) {
|
|
338
|
+
enter("frontier");
|
|
339
|
+
this.next();
|
|
340
|
+
frontier = this.atDepth(ctx.destination ? rowDepth - 1 : rowDepth, () => this.parseValueExpr());
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
// `depth n` — a follow destination block's own clause (0.20).
|
|
344
|
+
if (ctx.destination && this.at("ident", "depth") && this.peekAt(1)?.type === "number") {
|
|
345
|
+
enter("depth");
|
|
346
|
+
this.next();
|
|
347
|
+
const v = Number(this.next().value);
|
|
348
|
+
if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
|
|
349
|
+
depth = v;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
306
352
|
if (this.atFollow()) {
|
|
307
353
|
enter("follow");
|
|
308
354
|
follow = this.atDepth(rowDepth, () => this.parseFollow());
|
|
@@ -349,7 +395,12 @@ class Parser {
|
|
|
349
395
|
// surface form is kept. `where` first: its cycle report follows its own path.
|
|
350
396
|
if (where) resolveWhere(select, where, (msg) => this.fail(msg));
|
|
351
397
|
resolveSelect(select, (msg) => this.fail(msg));
|
|
352
|
-
|
|
398
|
+
// Outside a destination block `frontier` marks the seeds of this body's walk,
|
|
399
|
+
// so it needs one.
|
|
400
|
+
if (frontier && !follow && !ctx.destination) {
|
|
401
|
+
this.fail("`frontier` needs a `follow` — a body's `frontier <expr>` marks which of its rows (the walk's seeds) are frontiers; a destination block's marks the occurrences reached through it");
|
|
402
|
+
}
|
|
403
|
+
return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
|
|
353
404
|
}
|
|
354
405
|
|
|
355
406
|
// The keyword-less run that opens a body. At the top level it is always the
|
|
@@ -403,8 +454,8 @@ class Parser {
|
|
|
403
454
|
// consumer word where a whole-query directive was meant), or a stray token.
|
|
404
455
|
private failUnexpectedInBody(stage: number, ctx: BodyCtx): never {
|
|
405
456
|
const t = this.peek();
|
|
406
|
-
const remaining = CLAUSE_ORDER.slice(stage + 1).join("/");
|
|
407
457
|
const last = stage >= 0 ? CLAUSE_ORDER[stage] : null;
|
|
458
|
+
const remaining = clausesOf(ctx).filter((c) => CLAUSE_ORDER.indexOf(c) > stage).join("/");
|
|
408
459
|
const isWord = t.type === "ident" || t.type === "kw" || t.type === "binding" || t.type === "caret";
|
|
409
460
|
// Punctuation, an operator, or a literal after a complete clause is a stray
|
|
410
461
|
// token, not a misplaced predicate: name where the body ends. (A comma keeps
|
|
@@ -421,6 +472,11 @@ class Parser {
|
|
|
421
472
|
if (t.value === "limit" || t.value === "offset") {
|
|
422
473
|
this.fail(`unexpected '${t.value}' after \`${last}\` — a bound is a non-negative number literal, a binding, or (inside a block) an outer reference \`^name\``);
|
|
423
474
|
}
|
|
475
|
+
if (t.value === "depth" && !ctx.destination) {
|
|
476
|
+
this.fail(`unexpected 'depth' after \`${last}\` — \`depth\` is a clause of a follow destination block only (\`follow <relation> { depth 2 }\`)`);
|
|
477
|
+
}
|
|
478
|
+
if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
|
|
479
|
+
if (t.value === "frontier") this.fail(`unexpected 'frontier' after \`${last}\` — \`frontier\` takes an expression (\`frontier <expr>\`) and belongs to a body that has a \`follow\``);
|
|
424
480
|
}
|
|
425
481
|
if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
|
|
426
482
|
this.fail(`unexpected \`${t.value}\` after \`from\` — a whole-query consumer is written \`<collection> ${t.value} { … }\`; to project a field named ${t.value} write \`select ${t.value} from …\`; a predicate needs \`where\``);
|
|
@@ -493,12 +549,13 @@ class Parser {
|
|
|
493
549
|
private atFollow(): boolean {
|
|
494
550
|
if (!this.at("ident", "follow")) return false;
|
|
495
551
|
const nx = this.peekAt(1);
|
|
496
|
-
return !!nx && (nx.type === "ident" || nx.type === "binding" || this.atScopeRefAt(1));
|
|
552
|
+
return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "lparen" || this.atScopeRefAt(1));
|
|
497
553
|
}
|
|
498
554
|
|
|
499
|
-
// Whether the cursor is at something that can begin a `follow` destination
|
|
555
|
+
// Whether the cursor is at something that can begin a `follow` destination:
|
|
556
|
+
// a name, a binding, a scope reference, or a tuple's `(`.
|
|
500
557
|
private atDestinationStart(): boolean {
|
|
501
|
-
return this.at("ident") || this.at("binding") || this.atScopeRefAt(0);
|
|
558
|
+
return this.at("ident") || this.at("binding") || this.at("lparen") || this.atScopeRefAt(0);
|
|
502
559
|
}
|
|
503
560
|
|
|
504
561
|
// ---- follow ---------------------------------------------------------------
|
|
@@ -523,75 +580,31 @@ class Parser {
|
|
|
523
580
|
}
|
|
524
581
|
destinations.push(this.parseFollowDestination());
|
|
525
582
|
}
|
|
526
|
-
|
|
527
|
-
if (!this.at("lbrace")) return follow;
|
|
528
|
-
this.next(); // '{'
|
|
529
|
-
while (!this.at("eof") && !this.at("rbrace")) {
|
|
530
|
-
if (this.at("kw", "where")) {
|
|
531
|
-
if (follow.where) this.fail("duplicate `where` in follow clause");
|
|
532
|
-
this.next();
|
|
533
|
-
// Read in the successor's scope, one deeper than the frontier row.
|
|
534
|
-
follow.where = this.atDepth(this.depth + 1, () => this.parseValueExpr());
|
|
535
|
-
} else if (this.at("ident", "frontier")) {
|
|
536
|
-
if (follow.frontier) this.fail("duplicate `frontier` in follow clause");
|
|
537
|
-
this.next();
|
|
538
|
-
follow.frontier = this.parseValueExpr();
|
|
539
|
-
} else if (this.at("ident", "by")) {
|
|
540
|
-
if (follow.by !== null) this.fail("duplicate `by` in follow clause");
|
|
541
|
-
this.next();
|
|
542
|
-
follow.by = this.parseValueExpr();
|
|
543
|
-
} else if (this.at("ident", "depth")) {
|
|
544
|
-
if (follow.depth !== null) this.fail("duplicate `depth` in follow clause");
|
|
545
|
-
this.next();
|
|
546
|
-
if (!this.at("number")) this.fail("expected an integer after `depth`");
|
|
547
|
-
const v = Number(this.next().value);
|
|
548
|
-
if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
|
|
549
|
-
follow.depth = v;
|
|
550
|
-
} else {
|
|
551
|
-
this.fail(`unexpected ${this.tokDesc()} in follow block — expected where/frontier/depth/by (a brace after the last destination is the options block; a destination block needs its consumer: \`follow <relation> collect { … }\`)`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
if (!this.at("rbrace")) this.fail("expected '}' to close the follow block");
|
|
555
|
-
this.next();
|
|
556
|
-
follow.span = this.sp(start);
|
|
557
|
-
return follow;
|
|
583
|
+
return { kind: "follow", span: this.sp(start), destinations, distinct };
|
|
558
584
|
}
|
|
559
585
|
|
|
560
|
-
// One `follow` destination
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
586
|
+
// One `follow` destination: a receiver (a binding, a dotted name — headed by a
|
|
587
|
+
// bare name, an outer reference or a free-function call — or a tuple) that is
|
|
588
|
+
// either a plain relation of the current row or, when a block follows, a
|
|
589
|
+
// destination block. The block is what `tryOp` recognizes in select position
|
|
590
|
+
// — `{ … }` (the implicit `collect`), `distinct { … }`, or a consumer word and
|
|
591
|
+
// `{` / `distinct {` — read with the destination flag so its body admits
|
|
592
|
+
// `frontier` and `depth`. Only collect/first/single yield rows to walk; the
|
|
593
|
+
// where-position tests are rejected by name. A receiver headed by an outer
|
|
594
|
+
// reference can never be a plain destination (plain destinations are relations
|
|
595
|
+
// of the current row), so without a block it is an error naming the block
|
|
596
|
+
// spellings.
|
|
568
597
|
private parseFollowDestination(): FollowDestination {
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
if (
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
this.parseScopeRef();
|
|
575
|
-
if (this.at("ident")) { this.parseNavFrom(this.next(), 0, startPos); isReceiver = true; }
|
|
576
|
-
}
|
|
577
|
-
if (isReceiver && this.at("ident") && CONSUMERS.has(this.peek().value)) {
|
|
578
|
-
const op = this.peek().value;
|
|
579
|
-
const after = this.peekAt(1);
|
|
580
|
-
const opThenBrace = after?.type === "lbrace";
|
|
581
|
-
const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
|
|
582
|
-
if (opThenBrace || opDistinctBrace) {
|
|
583
|
-
if (op !== "collect" && op !== "first" && op !== "single") {
|
|
584
|
-
this.fail(`a follow destination must use collect/first/single, not \`${op}\` (exists/none/count are where-position tests)`);
|
|
585
|
-
}
|
|
586
|
-
this.pos = start;
|
|
587
|
-
const block = this.tryOp(false);
|
|
588
|
-
if (!block) this.fail("expected a destination block (`<receiver> collect { … }`) after `follow`");
|
|
589
|
-
return block;
|
|
598
|
+
const outer = this.atScopeRefAt(0);
|
|
599
|
+
const op = this.tryOp(false, true);
|
|
600
|
+
if (op) {
|
|
601
|
+
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
602
|
+
this.fail(`a follow destination must use collect/first/single, not \`${op.op}\` (exists/none/count are where-position tests)`);
|
|
590
603
|
}
|
|
604
|
+
return op;
|
|
591
605
|
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
this.fail("`follow` takes a relation of the current row (`follow <relation>`); an outer reference `^name` is not allowed there — it may only head a destination block (`follow ^name collect { … }`)");
|
|
606
|
+
if (outer) {
|
|
607
|
+
this.fail("`follow` takes a relation of the current row (`follow <relation>`); an outer reference walks a root collection only as a destination block — `follow ^docs { where … }` or `follow ^docs collect { … }`");
|
|
595
608
|
}
|
|
596
609
|
return this.parseReceiver();
|
|
597
610
|
}
|
|
@@ -603,6 +616,11 @@ class Parser {
|
|
|
603
616
|
// call (`entries(prefs)`), so a computed collection can be consumed directly.
|
|
604
617
|
private parseReceiver(): Expr {
|
|
605
618
|
if (this.at("binding")) return this.bindingNode(this.next());
|
|
619
|
+
if (this.at("lparen")) {
|
|
620
|
+
const t = this.tryTuple();
|
|
621
|
+
if (t) return t;
|
|
622
|
+
this.fail("expected a collection navigation (a property/relation name) or a tuple `(a, b)`");
|
|
623
|
+
}
|
|
606
624
|
const start = this.start();
|
|
607
625
|
const levels = this.parseScopeRef();
|
|
608
626
|
if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
|
|
@@ -787,6 +805,16 @@ class Parser {
|
|
|
787
805
|
else break;
|
|
788
806
|
}
|
|
789
807
|
if (this.at("lparen")) {
|
|
808
|
+
// A tuple (0.20) is a value, not a group: the receiver of a consumer test
|
|
809
|
+
// (`(a, b) exists { }`) or a scalar operand (`(a, b).size() > 1`).
|
|
810
|
+
const tuple = this.tryTuple();
|
|
811
|
+
if (tuple) {
|
|
812
|
+
if (this.atBlock()) return wrapNot(this.finishWhereOp(this.parseConsumerBlock(tuple, this.atBlock()!, true)), notStarts);
|
|
813
|
+
let e = this.parsePostfix(tuple);
|
|
814
|
+
for (let i = notStarts.length - 1; i >= 0; i--) e = { kind: "unary", span: [notStarts[i]!, e.span[1]], op: "!", expr: e };
|
|
815
|
+
const expr = this.parseCmp(e);
|
|
816
|
+
return { kind: "scalar", span: expr.span, expr };
|
|
817
|
+
}
|
|
790
818
|
const lparen = this.next().pos;
|
|
791
819
|
const inner = this.parseWhere();
|
|
792
820
|
if (!this.at("rparen")) this.fail("expected ')' to close a grouped where expression");
|
|
@@ -886,9 +914,11 @@ class Parser {
|
|
|
886
914
|
}
|
|
887
915
|
|
|
888
916
|
// Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }` — or
|
|
889
|
-
// its implicit-`collect`
|
|
890
|
-
// when the lookahead is not a
|
|
891
|
-
|
|
917
|
+
// its implicit-`collect` forms `<receiver> { <sub> }` and `<receiver> distinct
|
|
918
|
+
// { <sub> }` (0.20). Returns null (rewinding) when the lookahead is not a
|
|
919
|
+
// directive. `destination` marks a follow destination block (its body admits
|
|
920
|
+
// `frontier`/`depth`).
|
|
921
|
+
private tryOp(inWhere: boolean, destination = false): OpNode | null {
|
|
892
922
|
const start = this.pos;
|
|
893
923
|
const startPos = this.start();
|
|
894
924
|
let receiver: Expr;
|
|
@@ -899,7 +929,13 @@ class Parser {
|
|
|
899
929
|
// until the lookahead has decided.
|
|
900
930
|
let scopeErr: OqxError | null = null;
|
|
901
931
|
if (this.at("binding")) receiver = this.bindingNode(this.next());
|
|
902
|
-
else if (this.at("
|
|
932
|
+
else if (this.at("lparen")) {
|
|
933
|
+
// A tuple receiver, `(a, b) { … }`; a single parenthesized expression is
|
|
934
|
+
// grouping and never a receiver.
|
|
935
|
+
const t = this.tryTuple();
|
|
936
|
+
if (!t) { this.pos = start; return null; }
|
|
937
|
+
receiver = t;
|
|
938
|
+
} else if (this.at("ident") || this.atScopeRefAt(0)) {
|
|
903
939
|
let levels = 0;
|
|
904
940
|
try {
|
|
905
941
|
levels = this.parseScopeRef();
|
|
@@ -913,20 +949,29 @@ class Parser {
|
|
|
913
949
|
receiver = this.parseNavFrom(this.next(), levels, startPos).expr;
|
|
914
950
|
} else return null;
|
|
915
951
|
|
|
916
|
-
const op = this.
|
|
952
|
+
const op = this.atBlock();
|
|
917
953
|
if (op) {
|
|
918
954
|
if (scopeErr) throw scopeErr;
|
|
919
955
|
if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name)) this.fail(`\`${receiver.name}\` is a literal, not a collection`);
|
|
920
956
|
// `count { }` with nothing before it: the consumer word is not a receiver.
|
|
921
|
-
if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name)) {
|
|
957
|
+
if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name) && this.at("lbrace")) {
|
|
922
958
|
this.fail(`unexpected '{' after \`${receiver.name}\` — a consumer needs a receiver: \`<collection> ${receiver.name} { … }\``);
|
|
923
959
|
}
|
|
924
|
-
return this.parseConsumerBlock(receiver, op, inWhere);
|
|
960
|
+
return this.parseConsumerBlock(receiver, op, inWhere, destination);
|
|
925
961
|
}
|
|
926
962
|
this.pos = start;
|
|
927
963
|
return null;
|
|
928
964
|
}
|
|
929
965
|
|
|
966
|
+
// The cursor is at what opens a block over an already-read receiver: `{` or
|
|
967
|
+
// `distinct {` (the implicit `collect`), or a consumer word and `{` /
|
|
968
|
+
// `distinct {`. Consumes nothing.
|
|
969
|
+
private atBlock(): Consumer | null {
|
|
970
|
+
if (this.at("lbrace")) return "collect";
|
|
971
|
+
if (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace") return "collect";
|
|
972
|
+
return this.atConsumerBlock();
|
|
973
|
+
}
|
|
974
|
+
|
|
930
975
|
// The cursor is at a consumer word that opens a block — `<op> {` or
|
|
931
976
|
// `<op> distinct {`. Consumes nothing.
|
|
932
977
|
private atConsumerBlock(): Consumer | null {
|
|
@@ -938,17 +983,19 @@ class Parser {
|
|
|
938
983
|
}
|
|
939
984
|
|
|
940
985
|
// `[<op>] [distinct] { <sub> }` over an already-parsed receiver; the cursor is
|
|
941
|
-
// at the consumer word, or at the `{` of the implicit `collect`.
|
|
942
|
-
|
|
986
|
+
// at the consumer word, at `distinct`, or at the `{` of the implicit `collect`.
|
|
987
|
+
// `destination` marks a follow destination block (0.20): its body may carry
|
|
988
|
+
// `frontier` and `depth`.
|
|
989
|
+
private parseConsumerBlock(receiver: Expr, op: Consumer, inWhere: boolean, destination = false): OpNode {
|
|
943
990
|
const startPos = receiver.span[0];
|
|
944
|
-
if (this.at("ident")) this.next(); // the consumer word (absent for `x { … }`)
|
|
991
|
+
if (this.at("ident") && CONSUMERS.has(this.peek().value)) this.next(); // the consumer word (absent for `x { … }`)
|
|
945
992
|
let distinct = false;
|
|
946
993
|
if (this.at("ident", "distinct")) { this.next(); distinct = true; }
|
|
947
994
|
const lbrace = this.next().pos; // '{'
|
|
948
995
|
const defer = this.deferLookup;
|
|
949
996
|
this.deferLookup = false;
|
|
950
997
|
// The receiver was read at the enclosing depth; the block's rows are one deeper.
|
|
951
|
-
const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect" }));
|
|
998
|
+
const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
|
|
952
999
|
this.deferLookup = defer;
|
|
953
1000
|
if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
|
|
954
1001
|
this.next();
|
|
@@ -960,6 +1007,8 @@ class Parser {
|
|
|
960
1007
|
select: body.select,
|
|
961
1008
|
orderBy: body.orderBy,
|
|
962
1009
|
follow: body.follow,
|
|
1010
|
+
frontier: body.frontier,
|
|
1011
|
+
depth: body.depth,
|
|
963
1012
|
values: body.values,
|
|
964
1013
|
limit: body.limit,
|
|
965
1014
|
offset: body.offset,
|
|
@@ -967,6 +1016,27 @@ class Parser {
|
|
|
967
1016
|
return { kind: "op", span: this.sp(startPos), receiver, op, sub, countCmp: null, distinct: distinct || body.distinct };
|
|
968
1017
|
}
|
|
969
1018
|
|
|
1019
|
+
// A tuple `(e1, e2, …)` at the cursor's `(` (0.20): two or more comma-separated
|
|
1020
|
+
// expressions. A single parenthesized expression is grouping, not a tuple —
|
|
1021
|
+
// the cursor is rewound and null returned (also on a parse error, so the
|
|
1022
|
+
// caller's own reading reports its own error).
|
|
1023
|
+
private tryTuple(): Expr | null {
|
|
1024
|
+
const save = this.pos;
|
|
1025
|
+
const lparen = this.next().pos; // '('
|
|
1026
|
+
try {
|
|
1027
|
+
const items = [this.parseValueExpr()];
|
|
1028
|
+
if (!this.at("comma")) { this.pos = save; return null; }
|
|
1029
|
+
while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
|
|
1030
|
+
if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
|
|
1031
|
+
this.next();
|
|
1032
|
+
return { kind: "tuple", span: [lparen, this.end()], items };
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
if (!(err instanceof OqxError)) throw err;
|
|
1035
|
+
this.pos = save;
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
970
1040
|
// A bracket suffix on `recv` (GRAMMAR §4): `[n]` / `[${…}]` is positional —
|
|
971
1041
|
// `recv first { offset n }` — and anything else a predicate —
|
|
972
1042
|
// `recv first { where p }`; a trailing `!` makes the index required and the
|
|
@@ -997,7 +1067,7 @@ class Parser {
|
|
|
997
1067
|
this.next();
|
|
998
1068
|
const subSpan: Span = [lbracket, this.end()];
|
|
999
1069
|
const sub = (parts: Partial<Subquery>): Subquery => ({
|
|
1000
|
-
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, values: false, limit: null, offset: null, ...parts,
|
|
1070
|
+
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
|
|
1001
1071
|
});
|
|
1002
1072
|
const bang = this.atOp("!") ? this.next() : null;
|
|
1003
1073
|
if (index) {
|
|
@@ -1093,7 +1163,11 @@ class Parser {
|
|
|
1093
1163
|
// consumers, …) are lexed as bare idents, so they must NOT count as a value
|
|
1094
1164
|
// start, or `where age in 18.. order by name` would read `order` as the bound.
|
|
1095
1165
|
private canStartValue(): boolean {
|
|
1096
|
-
|
|
1166
|
+
return this.startsValue(this.peek());
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private startsValue(t: Token | undefined): boolean {
|
|
1170
|
+
if (!t) return false;
|
|
1097
1171
|
if (t.type === "ident") return !CLAUSE_WORDS.has(t.value);
|
|
1098
1172
|
if (t.type === "kw") return t.value === "is" || t.value === "not"; // the prefix operators
|
|
1099
1173
|
if (t.type === "number" || t.type === "string" ||
|
|
@@ -1162,14 +1236,16 @@ class Parser {
|
|
|
1162
1236
|
// never places two operands side by side, so it cannot begin one here.
|
|
1163
1237
|
const bang = this.next();
|
|
1164
1238
|
expr = { kind: "required", span: [start, bang.end], expr };
|
|
1165
|
-
} else if (this.atValueDirective()) {
|
|
1239
|
+
} else if (this.atValueDirective() || this.at("lbrace") || (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace")) {
|
|
1166
1240
|
// A value-position directive, `recv first { … }` — how a bracket chain
|
|
1167
|
-
// prints back
|
|
1168
|
-
// the
|
|
1241
|
+
// prints back — or the implicit `collect` of a bare `{ … }` / `distinct
|
|
1242
|
+
// { … }` (0.20: the receiver-block sugar stands wherever a value does,
|
|
1243
|
+
// `size(children { is checked })`). exists/none/count have no value form
|
|
1244
|
+
// and are left to the body's own error.
|
|
1169
1245
|
if (expr.kind === "lit") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
|
|
1170
|
-
expr = this.parseConsumerBlock(expr, this.
|
|
1246
|
+
expr = this.parseConsumerBlock(expr, this.atBlock()!, false);
|
|
1171
1247
|
} else {
|
|
1172
|
-
break; //
|
|
1248
|
+
break; // anything else ends the chain
|
|
1173
1249
|
}
|
|
1174
1250
|
}
|
|
1175
1251
|
return expr;
|
|
@@ -1210,6 +1286,14 @@ class Parser {
|
|
|
1210
1286
|
if (t.type === "lparen") {
|
|
1211
1287
|
this.next();
|
|
1212
1288
|
const e = this.parseValueExpr();
|
|
1289
|
+
if (this.at("comma")) {
|
|
1290
|
+
// A tuple `(e1, e2, …)` (0.20): two or more elements.
|
|
1291
|
+
const items = [e];
|
|
1292
|
+
while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
|
|
1293
|
+
if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
|
|
1294
|
+
this.next();
|
|
1295
|
+
return { kind: "tuple", span: [t.pos, this.end()], items };
|
|
1296
|
+
}
|
|
1213
1297
|
if (!this.at("rparen")) this.fail("expected ')'");
|
|
1214
1298
|
this.next();
|
|
1215
1299
|
return withSpan(e, [t.pos, this.end()]);
|
package/src/print.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Single spaces, double-quoted strings with GRAMMAR §1 escapes, the minimal
|
|
3
3
|
// parentheses the precedence table (GRAMMAR §4) needs, `select` written at the
|
|
4
4
|
// top level, the connectives as the words `and` / `or` (since 0.18; `!` stays
|
|
5
|
-
// `!`), every
|
|
5
|
+
// `!`), every clause in its canonical position (a destination block's
|
|
6
|
+
// `frontier` and `depth` after its `where`, since 0.20). The law both spec runners
|
|
6
7
|
// enforce over every fixture query:
|
|
7
8
|
//
|
|
8
9
|
// strip(parse(print(parse(q)))) ≡ strip(parse(q))
|
|
@@ -65,7 +66,7 @@ const OR = 1, AND = 2, CMP = 3, RANGE = 4, ADD = 5, MUL = 6, UNARY = 7, POSTFIX
|
|
|
65
66
|
|
|
66
67
|
function prec(e: Expr): number {
|
|
67
68
|
switch (e.kind) {
|
|
68
|
-
case "lit": case "ident": case "outer": case "binding": return PRIMARY;
|
|
69
|
+
case "lit": case "ident": case "outer": case "binding": case "tuple": return PRIMARY;
|
|
69
70
|
case "member": case "call": case "required": case "op": return POSTFIX;
|
|
70
71
|
case "unary": return UNARY;
|
|
71
72
|
case "binary": return e.op === "+" || e.op === "-" ? ADD : e.op === "*" || e.op === "/" || e.op === "%" ? MUL : CMP;
|
|
@@ -113,14 +114,18 @@ function query(q: Query): Out {
|
|
|
113
114
|
return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "orderBy" | "limit" | "offset"
|
|
117
|
+
type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "orderBy" | "limit" | "offset">
|
|
118
|
+
& Partial<Pick<Subquery, "frontier" | "depth">>;
|
|
117
119
|
|
|
118
120
|
// The clauses after `from` (a query) or after the projection (a block): the
|
|
119
|
-
// fixed clause order, each present clause once.
|
|
121
|
+
// fixed clause order, each present clause once. `frontier`/`depth` exist only
|
|
122
|
+
// on a follow destination block's body (a `Query` has neither).
|
|
120
123
|
function tail(b: Omit<Body, "select" | "values">): Out[] {
|
|
121
124
|
const out: Out[] = [];
|
|
122
125
|
for (const e of b.from) out.push(cat("from ", expr(e)));
|
|
123
126
|
if (b.where) out.push(cat("where ", where(b.where)));
|
|
127
|
+
if (b.frontier) out.push(cat("frontier ", expr(b.frontier)));
|
|
128
|
+
if (b.depth != null) out.push([`depth ${number(b.depth)}`]);
|
|
124
129
|
if (b.follow) out.push(follow(b.follow));
|
|
125
130
|
if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
|
|
126
131
|
if (b.limit) out.push(cat("limit ", expr(b.limit)));
|
|
@@ -170,12 +175,7 @@ function op(o: OpNode): Out {
|
|
|
170
175
|
|
|
171
176
|
function follow(f: Follow): Out {
|
|
172
177
|
const dests = join(f.destinations.map((d) => (d.kind === "op" ? op(d) : expr(d))), ", ");
|
|
173
|
-
|
|
174
|
-
if (f.where) opts.push(cat("where ", expr(f.where)));
|
|
175
|
-
if (f.frontier) opts.push(cat("frontier ", expr(f.frontier)));
|
|
176
|
-
if (f.depth !== null) opts.push([`depth ${f.depth}`]);
|
|
177
|
-
if (f.by) opts.push(cat("by ", expr(f.by)));
|
|
178
|
-
return cat(`follow${f.distinct ? " distinct" : ""} `, dests, opts.length > 0 ? cat(" { ", join(opts, " "), " }") : "");
|
|
178
|
+
return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
// ---- where ------------------------------------------------------------------
|
|
@@ -229,6 +229,8 @@ function expr(e: Expr): Out {
|
|
|
229
229
|
case "in": return cat(operand(e.left, CMP + 1), " in ", operand(e.right, CMP + 1));
|
|
230
230
|
case "range":
|
|
231
231
|
return cat(e.lo ? operand(e.lo, ADD) : "", e.exclusiveEnd ? "..." : "..", e.hi ? operand(e.hi, ADD) : "");
|
|
232
|
+
// A tuple is self-delimiting; its elements are full expressions.
|
|
233
|
+
case "tuple": return cat("(", join(e.items.map(expr), ", "), ")");
|
|
232
234
|
}
|
|
233
235
|
}
|
|
234
236
|
|