@omgbase/oqx 0.18.0 → 0.21.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 +30 -0
- package/README.md +177 -64
- package/dist/ast.d.ts +28 -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 +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +10 -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 +263 -84
- 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/lexer.d.ts +3 -3
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +79 -2
- package/dist/lexer.js.map +1 -1
- package/dist/optimize/analysis.d.ts +3 -2
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +18 -15
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +250 -118
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +47 -29
- package/dist/print.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +15 -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 +34 -11
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +33 -6
- package/src/build.ts +12 -8
- package/src/context.ts +22 -11
- package/src/engine.ts +258 -91
- package/src/index.ts +2 -1
- package/src/lexer.ts +76 -6
- package/src/optimize/analysis.ts +17 -12
- package/src/parser.ts +227 -101
- package/src/print.ts +44 -16
- package/src/resolve.ts +15 -7
- package/src/version.ts +1 -1
- package/src/walk.ts +39 -14
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,14 @@
|
|
|
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
|
+
//
|
|
67
|
+
// Template literals (0.21): `` `text ${expr} text` `` is a `template` node — a
|
|
68
|
+
// primary value whose interpolations are full expressions read at the current
|
|
69
|
+
// depth (`parseTemplateLiteral`); the lexer balances the braces, so an
|
|
70
|
+
// interpolation may hold a `{ … }` block or another template literal.
|
|
71
|
+
//
|
|
60
72
|
// Principle of least surprise, applied to the grammar: a rule a careful user
|
|
61
73
|
// would not predict is a bug. Hence `!` binds tighter than comparison in every
|
|
62
74
|
// position (`!a == b` is `(!a) == b`, as in C); parentheses in `where` group a
|
|
@@ -71,15 +83,24 @@ import { lexTemplate, lexString } from "./lexer.ts";
|
|
|
71
83
|
import { OqxError } from "./errors.ts";
|
|
72
84
|
import type {
|
|
73
85
|
Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
|
|
74
|
-
BinaryOp, Span,
|
|
86
|
+
BinaryOp, Span, TemplatePart,
|
|
75
87
|
} from "./ast.ts";
|
|
76
88
|
import { resolveSelect, resolveWhere } from "./resolve.ts";
|
|
77
89
|
import { printTemplate } from "./print.ts";
|
|
78
90
|
|
|
79
91
|
const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
|
|
80
92
|
// The fixed clause order of a body (ADR-020). Each clause appears at most once.
|
|
81
|
-
|
|
93
|
+
// `frontier` is a clause of every body that has a `follow` (a destination
|
|
94
|
+
// block's marks the occurrences it reaches, any other body's its seeds — 0.20);
|
|
95
|
+
// `depth` is legal only in a follow destination block, and the order sentence
|
|
96
|
+
// names it only there.
|
|
97
|
+
const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "order by", "limit", "offset"] as const;
|
|
82
98
|
type Clause = (typeof CLAUSE_ORDER)[number];
|
|
99
|
+
const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
|
|
100
|
+
// The clauses a body may hold, in order.
|
|
101
|
+
function clausesOf(ctx: BodyCtx): Clause[] {
|
|
102
|
+
return CLAUSE_ORDER.filter((c) => ctx.destination || !DESTINATION_ONLY.has(c));
|
|
103
|
+
}
|
|
83
104
|
// Contextual clause words lexed as bare idents; an open-ended range must stop
|
|
84
105
|
// before them rather than consume them as its high bound.
|
|
85
106
|
const CLAUSE_WORDS = new Set<string>([
|
|
@@ -103,8 +124,12 @@ interface BodyCtx {
|
|
|
103
124
|
op: Consumer | null;
|
|
104
125
|
/** Whether `^name: expr` lift items are legal: only in a where-position `collect { … }`. */
|
|
105
126
|
liftsAllowed: boolean;
|
|
127
|
+
/** A `follow` destination block (0.20): the only body that admits `depth`, and
|
|
128
|
+
* the one whose `frontier` marks the occurrences it reaches (read one level out)
|
|
129
|
+
* rather than the seeds of its own `follow`. */
|
|
130
|
+
destination: boolean;
|
|
106
131
|
}
|
|
107
|
-
const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false };
|
|
132
|
+
const TOP_CTX: BodyCtx = { top: true, op: null, liftsAllowed: false, destination: false };
|
|
108
133
|
|
|
109
134
|
/** Parse a tagged-template call into a Query. */
|
|
110
135
|
export function parseTemplate(fragments: readonly string[], values: number): Query {
|
|
@@ -122,6 +147,8 @@ interface BodyClauses {
|
|
|
122
147
|
select: SelectItem[];
|
|
123
148
|
orderBy: OrderSpec[] | null;
|
|
124
149
|
follow: Follow | null;
|
|
150
|
+
frontier: Expr | null;
|
|
151
|
+
depth: number | null;
|
|
125
152
|
distinct: boolean;
|
|
126
153
|
values: boolean;
|
|
127
154
|
limit: Expr | null;
|
|
@@ -172,7 +199,10 @@ class Parser {
|
|
|
172
199
|
return t.type === "op" && t.value === value;
|
|
173
200
|
}
|
|
174
201
|
private fail(msg: string): never {
|
|
175
|
-
|
|
202
|
+
this.failAt(this.peek().pos, msg);
|
|
203
|
+
}
|
|
204
|
+
private failAt(pos: number, msg: string): never {
|
|
205
|
+
throw new OqxError(`${msg} (at offset ${pos})`, "parse");
|
|
176
206
|
}
|
|
177
207
|
/** Code-point offset of the next token (the start of a node about to be parsed). */
|
|
178
208
|
private start(): number { return this.peek().pos; }
|
|
@@ -231,6 +261,7 @@ class Parser {
|
|
|
231
261
|
orderBy: body.orderBy,
|
|
232
262
|
consumer: "collect",
|
|
233
263
|
follow: body.follow,
|
|
264
|
+
frontier: body.frontier,
|
|
234
265
|
distinct: body.distinct,
|
|
235
266
|
values: body.values,
|
|
236
267
|
limit: body.limit,
|
|
@@ -243,7 +274,7 @@ class Parser {
|
|
|
243
274
|
private queryFromOp(e: OpNode, start: number): Query {
|
|
244
275
|
return {
|
|
245
276
|
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,
|
|
277
|
+
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, distinct: e.distinct, values: e.sub.values,
|
|
247
278
|
limit: e.sub.limit, offset: e.sub.offset,
|
|
248
279
|
};
|
|
249
280
|
}
|
|
@@ -264,6 +295,8 @@ class Parser {
|
|
|
264
295
|
let select: SelectItem[] = [];
|
|
265
296
|
let orderBy: OrderSpec[] | null = null;
|
|
266
297
|
let follow: Follow | null = null;
|
|
298
|
+
let frontier: Expr | null = null;
|
|
299
|
+
let depth: number | null = null;
|
|
267
300
|
let distinct = false;
|
|
268
301
|
let values = false;
|
|
269
302
|
let limit: Expr | null = null;
|
|
@@ -278,7 +311,7 @@ class Parser {
|
|
|
278
311
|
const idx = CLAUSE_ORDER.indexOf(clause);
|
|
279
312
|
if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
|
|
280
313
|
if (idx < stage) {
|
|
281
|
-
this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${
|
|
314
|
+
this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
|
|
282
315
|
}
|
|
283
316
|
stage = idx;
|
|
284
317
|
};
|
|
@@ -303,6 +336,27 @@ class Parser {
|
|
|
303
336
|
where = this.atDepth(rowDepth, () => this.parseWhere());
|
|
304
337
|
continue;
|
|
305
338
|
}
|
|
339
|
+
// `frontier P` (0.20) — in a follow destination block the destination's
|
|
340
|
+
// own clause, read in the occurrence's scope whose parent is the walk's
|
|
341
|
+
// enclosing scope: one level OUT of the block's rows (SEMANTICS §20), so
|
|
342
|
+
// `^` there is the enclosing scope, not the frontier row. In any other
|
|
343
|
+
// body it marks the seeds of the body's own `follow` (checked below) and
|
|
344
|
+
// is read in the seed's scope, like `where`.
|
|
345
|
+
if (this.at("ident", "frontier") && this.startsValue(this.peekAt(1))) {
|
|
346
|
+
enter("frontier");
|
|
347
|
+
this.next();
|
|
348
|
+
frontier = this.atDepth(ctx.destination ? rowDepth - 1 : rowDepth, () => this.parseValueExpr());
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// `depth n` — a follow destination block's own clause (0.20).
|
|
352
|
+
if (ctx.destination && this.at("ident", "depth") && this.peekAt(1)?.type === "number") {
|
|
353
|
+
enter("depth");
|
|
354
|
+
this.next();
|
|
355
|
+
const v = Number(this.next().value);
|
|
356
|
+
if (!Number.isInteger(v) || v < 1 || v > 8) this.fail("follow depth must be an integer between 1 and 8");
|
|
357
|
+
depth = v;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
306
360
|
if (this.atFollow()) {
|
|
307
361
|
enter("follow");
|
|
308
362
|
follow = this.atDepth(rowDepth, () => this.parseFollow());
|
|
@@ -349,7 +403,12 @@ class Parser {
|
|
|
349
403
|
// surface form is kept. `where` first: its cycle report follows its own path.
|
|
350
404
|
if (where) resolveWhere(select, where, (msg) => this.fail(msg));
|
|
351
405
|
resolveSelect(select, (msg) => this.fail(msg));
|
|
352
|
-
|
|
406
|
+
// Outside a destination block `frontier` marks the seeds of this body's walk,
|
|
407
|
+
// so it needs one.
|
|
408
|
+
if (frontier && !follow && !ctx.destination) {
|
|
409
|
+
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");
|
|
410
|
+
}
|
|
411
|
+
return { froms, where, select, orderBy, follow, frontier, depth, distinct, values, limit, offset };
|
|
353
412
|
}
|
|
354
413
|
|
|
355
414
|
// The keyword-less run that opens a body. At the top level it is always the
|
|
@@ -403,8 +462,8 @@ class Parser {
|
|
|
403
462
|
// consumer word where a whole-query directive was meant), or a stray token.
|
|
404
463
|
private failUnexpectedInBody(stage: number, ctx: BodyCtx): never {
|
|
405
464
|
const t = this.peek();
|
|
406
|
-
const remaining = CLAUSE_ORDER.slice(stage + 1).join("/");
|
|
407
465
|
const last = stage >= 0 ? CLAUSE_ORDER[stage] : null;
|
|
466
|
+
const remaining = clausesOf(ctx).filter((c) => CLAUSE_ORDER.indexOf(c) > stage).join("/");
|
|
408
467
|
const isWord = t.type === "ident" || t.type === "kw" || t.type === "binding" || t.type === "caret";
|
|
409
468
|
// Punctuation, an operator, or a literal after a complete clause is a stray
|
|
410
469
|
// token, not a misplaced predicate: name where the body ends. (A comma keeps
|
|
@@ -421,6 +480,11 @@ class Parser {
|
|
|
421
480
|
if (t.value === "limit" || t.value === "offset") {
|
|
422
481
|
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
482
|
}
|
|
483
|
+
if (t.value === "depth" && !ctx.destination) {
|
|
484
|
+
this.fail(`unexpected 'depth' after \`${last}\` — \`depth\` is a clause of a follow destination block only (\`follow <relation> { depth 2 }\`)`);
|
|
485
|
+
}
|
|
486
|
+
if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
|
|
487
|
+
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
488
|
}
|
|
425
489
|
if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
|
|
426
490
|
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 +557,13 @@ class Parser {
|
|
|
493
557
|
private atFollow(): boolean {
|
|
494
558
|
if (!this.at("ident", "follow")) return false;
|
|
495
559
|
const nx = this.peekAt(1);
|
|
496
|
-
return !!nx && (nx.type === "ident" || nx.type === "binding" || this.atScopeRefAt(1));
|
|
560
|
+
return !!nx && (nx.type === "ident" || nx.type === "binding" || nx.type === "lparen" || this.atScopeRefAt(1));
|
|
497
561
|
}
|
|
498
562
|
|
|
499
|
-
// Whether the cursor is at something that can begin a `follow` destination
|
|
563
|
+
// Whether the cursor is at something that can begin a `follow` destination:
|
|
564
|
+
// a name, a binding, a scope reference, or a tuple's `(`.
|
|
500
565
|
private atDestinationStart(): boolean {
|
|
501
|
-
return this.at("ident") || this.at("binding") || this.atScopeRefAt(0);
|
|
566
|
+
return this.at("ident") || this.at("binding") || this.at("lparen") || this.atScopeRefAt(0);
|
|
502
567
|
}
|
|
503
568
|
|
|
504
569
|
// ---- follow ---------------------------------------------------------------
|
|
@@ -523,75 +588,31 @@ class Parser {
|
|
|
523
588
|
}
|
|
524
589
|
destinations.push(this.parseFollowDestination());
|
|
525
590
|
}
|
|
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;
|
|
591
|
+
return { kind: "follow", span: this.sp(start), destinations, distinct };
|
|
558
592
|
}
|
|
559
593
|
|
|
560
|
-
// One `follow` destination
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
594
|
+
// One `follow` destination: a receiver (a binding, a dotted name — headed by a
|
|
595
|
+
// bare name, an outer reference or a free-function call — or a tuple) that is
|
|
596
|
+
// either a plain relation of the current row or, when a block follows, a
|
|
597
|
+
// destination block. The block is what `tryOp` recognizes in select position
|
|
598
|
+
// — `{ … }` (the implicit `collect`), `distinct { … }`, or a consumer word and
|
|
599
|
+
// `{` / `distinct {` — read with the destination flag so its body admits
|
|
600
|
+
// `frontier` and `depth`. Only collect/first/single yield rows to walk; the
|
|
601
|
+
// where-position tests are rejected by name. A receiver headed by an outer
|
|
602
|
+
// reference can never be a plain destination (plain destinations are relations
|
|
603
|
+
// of the current row), so without a block it is an error naming the block
|
|
604
|
+
// spellings.
|
|
568
605
|
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;
|
|
606
|
+
const outer = this.atScopeRefAt(0);
|
|
607
|
+
const op = this.tryOp(false, true);
|
|
608
|
+
if (op) {
|
|
609
|
+
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
610
|
+
this.fail(`a follow destination must use collect/first/single, not \`${op.op}\` (exists/none/count are where-position tests)`);
|
|
590
611
|
}
|
|
612
|
+
return op;
|
|
591
613
|
}
|
|
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 { … }`)");
|
|
614
|
+
if (outer) {
|
|
615
|
+
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
616
|
}
|
|
596
617
|
return this.parseReceiver();
|
|
597
618
|
}
|
|
@@ -603,6 +624,11 @@ class Parser {
|
|
|
603
624
|
// call (`entries(prefs)`), so a computed collection can be consumed directly.
|
|
604
625
|
private parseReceiver(): Expr {
|
|
605
626
|
if (this.at("binding")) return this.bindingNode(this.next());
|
|
627
|
+
if (this.at("lparen")) {
|
|
628
|
+
const t = this.tryTuple();
|
|
629
|
+
if (t) return t;
|
|
630
|
+
this.fail("expected a collection navigation (a property/relation name) or a tuple `(a, b)`");
|
|
631
|
+
}
|
|
606
632
|
const start = this.start();
|
|
607
633
|
const levels = this.parseScopeRef();
|
|
608
634
|
if (!this.at("ident")) this.fail("expected a collection navigation (a property/relation name)");
|
|
@@ -787,6 +813,16 @@ class Parser {
|
|
|
787
813
|
else break;
|
|
788
814
|
}
|
|
789
815
|
if (this.at("lparen")) {
|
|
816
|
+
// A tuple (0.20) is a value, not a group: the receiver of a consumer test
|
|
817
|
+
// (`(a, b) exists { }`) or a scalar operand (`(a, b).size() > 1`).
|
|
818
|
+
const tuple = this.tryTuple();
|
|
819
|
+
if (tuple) {
|
|
820
|
+
if (this.atBlock()) return wrapNot(this.finishWhereOp(this.parseConsumerBlock(tuple, this.atBlock()!, true)), notStarts);
|
|
821
|
+
let e = this.parsePostfix(tuple);
|
|
822
|
+
for (let i = notStarts.length - 1; i >= 0; i--) e = { kind: "unary", span: [notStarts[i]!, e.span[1]], op: "!", expr: e };
|
|
823
|
+
const expr = this.parseCmp(e);
|
|
824
|
+
return { kind: "scalar", span: expr.span, expr };
|
|
825
|
+
}
|
|
790
826
|
const lparen = this.next().pos;
|
|
791
827
|
const inner = this.parseWhere();
|
|
792
828
|
if (!this.at("rparen")) this.fail("expected ')' to close a grouped where expression");
|
|
@@ -886,9 +922,11 @@ class Parser {
|
|
|
886
922
|
}
|
|
887
923
|
|
|
888
924
|
// Detect + parse a postfix consumer op `<receiver> <consumer> { <sub> }` — or
|
|
889
|
-
// its implicit-`collect`
|
|
890
|
-
// when the lookahead is not a
|
|
891
|
-
|
|
925
|
+
// its implicit-`collect` forms `<receiver> { <sub> }` and `<receiver> distinct
|
|
926
|
+
// { <sub> }` (0.20). Returns null (rewinding) when the lookahead is not a
|
|
927
|
+
// directive. `destination` marks a follow destination block (its body admits
|
|
928
|
+
// `frontier`/`depth`).
|
|
929
|
+
private tryOp(inWhere: boolean, destination = false): OpNode | null {
|
|
892
930
|
const start = this.pos;
|
|
893
931
|
const startPos = this.start();
|
|
894
932
|
let receiver: Expr;
|
|
@@ -899,7 +937,13 @@ class Parser {
|
|
|
899
937
|
// until the lookahead has decided.
|
|
900
938
|
let scopeErr: OqxError | null = null;
|
|
901
939
|
if (this.at("binding")) receiver = this.bindingNode(this.next());
|
|
902
|
-
else if (this.at("
|
|
940
|
+
else if (this.at("lparen")) {
|
|
941
|
+
// A tuple receiver, `(a, b) { … }`; a single parenthesized expression is
|
|
942
|
+
// grouping and never a receiver.
|
|
943
|
+
const t = this.tryTuple();
|
|
944
|
+
if (!t) { this.pos = start; return null; }
|
|
945
|
+
receiver = t;
|
|
946
|
+
} else if (this.at("ident") || this.atScopeRefAt(0)) {
|
|
903
947
|
let levels = 0;
|
|
904
948
|
try {
|
|
905
949
|
levels = this.parseScopeRef();
|
|
@@ -913,20 +957,29 @@ class Parser {
|
|
|
913
957
|
receiver = this.parseNavFrom(this.next(), levels, startPos).expr;
|
|
914
958
|
} else return null;
|
|
915
959
|
|
|
916
|
-
const op = this.
|
|
960
|
+
const op = this.atBlock();
|
|
917
961
|
if (op) {
|
|
918
962
|
if (scopeErr) throw scopeErr;
|
|
919
963
|
if (receiver.kind === "ident" && LITERAL_WORDS.has(receiver.name)) this.fail(`\`${receiver.name}\` is a literal, not a collection`);
|
|
920
964
|
// `count { }` with nothing before it: the consumer word is not a receiver.
|
|
921
|
-
if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name)) {
|
|
965
|
+
if (op === "collect" && receiver.kind === "ident" && CONSUMERS.has(receiver.name) && this.at("lbrace")) {
|
|
922
966
|
this.fail(`unexpected '{' after \`${receiver.name}\` — a consumer needs a receiver: \`<collection> ${receiver.name} { … }\``);
|
|
923
967
|
}
|
|
924
|
-
return this.parseConsumerBlock(receiver, op, inWhere);
|
|
968
|
+
return this.parseConsumerBlock(receiver, op, inWhere, destination);
|
|
925
969
|
}
|
|
926
970
|
this.pos = start;
|
|
927
971
|
return null;
|
|
928
972
|
}
|
|
929
973
|
|
|
974
|
+
// The cursor is at what opens a block over an already-read receiver: `{` or
|
|
975
|
+
// `distinct {` (the implicit `collect`), or a consumer word and `{` /
|
|
976
|
+
// `distinct {`. Consumes nothing.
|
|
977
|
+
private atBlock(): Consumer | null {
|
|
978
|
+
if (this.at("lbrace")) return "collect";
|
|
979
|
+
if (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace") return "collect";
|
|
980
|
+
return this.atConsumerBlock();
|
|
981
|
+
}
|
|
982
|
+
|
|
930
983
|
// The cursor is at a consumer word that opens a block — `<op> {` or
|
|
931
984
|
// `<op> distinct {`. Consumes nothing.
|
|
932
985
|
private atConsumerBlock(): Consumer | null {
|
|
@@ -938,17 +991,19 @@ class Parser {
|
|
|
938
991
|
}
|
|
939
992
|
|
|
940
993
|
// `[<op>] [distinct] { <sub> }` over an already-parsed receiver; the cursor is
|
|
941
|
-
// at the consumer word, or at the `{` of the implicit `collect`.
|
|
942
|
-
|
|
994
|
+
// at the consumer word, at `distinct`, or at the `{` of the implicit `collect`.
|
|
995
|
+
// `destination` marks a follow destination block (0.20): its body may carry
|
|
996
|
+
// `frontier` and `depth`.
|
|
997
|
+
private parseConsumerBlock(receiver: Expr, op: Consumer, inWhere: boolean, destination = false): OpNode {
|
|
943
998
|
const startPos = receiver.span[0];
|
|
944
|
-
if (this.at("ident")) this.next(); // the consumer word (absent for `x { … }`)
|
|
999
|
+
if (this.at("ident") && CONSUMERS.has(this.peek().value)) this.next(); // the consumer word (absent for `x { … }`)
|
|
945
1000
|
let distinct = false;
|
|
946
1001
|
if (this.at("ident", "distinct")) { this.next(); distinct = true; }
|
|
947
1002
|
const lbrace = this.next().pos; // '{'
|
|
948
1003
|
const defer = this.deferLookup;
|
|
949
1004
|
this.deferLookup = false;
|
|
950
1005
|
// 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" }));
|
|
1006
|
+
const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
|
|
952
1007
|
this.deferLookup = defer;
|
|
953
1008
|
if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
|
|
954
1009
|
this.next();
|
|
@@ -960,6 +1015,8 @@ class Parser {
|
|
|
960
1015
|
select: body.select,
|
|
961
1016
|
orderBy: body.orderBy,
|
|
962
1017
|
follow: body.follow,
|
|
1018
|
+
frontier: body.frontier,
|
|
1019
|
+
depth: body.depth,
|
|
963
1020
|
values: body.values,
|
|
964
1021
|
limit: body.limit,
|
|
965
1022
|
offset: body.offset,
|
|
@@ -967,12 +1024,33 @@ class Parser {
|
|
|
967
1024
|
return { kind: "op", span: this.sp(startPos), receiver, op, sub, countCmp: null, distinct: distinct || body.distinct };
|
|
968
1025
|
}
|
|
969
1026
|
|
|
1027
|
+
// A tuple `(e1, e2, …)` at the cursor's `(` (0.20): two or more comma-separated
|
|
1028
|
+
// expressions. A single parenthesized expression is grouping, not a tuple —
|
|
1029
|
+
// the cursor is rewound and null returned (also on a parse error, so the
|
|
1030
|
+
// caller's own reading reports its own error).
|
|
1031
|
+
private tryTuple(): Expr | null {
|
|
1032
|
+
const save = this.pos;
|
|
1033
|
+
const lparen = this.next().pos; // '('
|
|
1034
|
+
try {
|
|
1035
|
+
const items = [this.parseValueExpr()];
|
|
1036
|
+
if (!this.at("comma")) { this.pos = save; return null; }
|
|
1037
|
+
while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
|
|
1038
|
+
if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
|
|
1039
|
+
this.next();
|
|
1040
|
+
return { kind: "tuple", span: [lparen, this.end()], items };
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
if (!(err instanceof OqxError)) throw err;
|
|
1043
|
+
this.pos = save;
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
970
1048
|
// A bracket suffix on `recv` (GRAMMAR §4): `[n]` / `[${…}]` is positional —
|
|
971
1049
|
// `recv first { offset n }` — and anything else a predicate —
|
|
972
1050
|
// `recv first { where p }`; a trailing `!` makes the index required and the
|
|
973
1051
|
// predicate a `single` (required too, so that zero matches is an error).
|
|
974
1052
|
private parseBracket(recv: Expr): Expr {
|
|
975
|
-
if (recv.kind === "lit") this.fail(`\`${describe(recv)}\` is a literal, not a collection`);
|
|
1053
|
+
if (recv.kind === "lit" || recv.kind === "template") this.fail(`\`${describe(recv)}\` is a literal, not a collection`);
|
|
976
1054
|
const start = recv.span[0];
|
|
977
1055
|
const lbracket = this.next().pos; // '['
|
|
978
1056
|
let index: Expr | null = null;
|
|
@@ -997,7 +1075,7 @@ class Parser {
|
|
|
997
1075
|
this.next();
|
|
998
1076
|
const subSpan: Span = [lbracket, this.end()];
|
|
999
1077
|
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,
|
|
1078
|
+
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
|
|
1001
1079
|
});
|
|
1002
1080
|
const bang = this.atOp("!") ? this.next() : null;
|
|
1003
1081
|
if (index) {
|
|
@@ -1093,10 +1171,14 @@ class Parser {
|
|
|
1093
1171
|
// consumers, …) are lexed as bare idents, so they must NOT count as a value
|
|
1094
1172
|
// start, or `where age in 18.. order by name` would read `order` as the bound.
|
|
1095
1173
|
private canStartValue(): boolean {
|
|
1096
|
-
|
|
1174
|
+
return this.startsValue(this.peek());
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private startsValue(t: Token | undefined): boolean {
|
|
1178
|
+
if (!t) return false;
|
|
1097
1179
|
if (t.type === "ident") return !CLAUSE_WORDS.has(t.value);
|
|
1098
1180
|
if (t.type === "kw") return t.value === "is" || t.value === "not"; // the prefix operators
|
|
1099
|
-
if (t.type === "number" || t.type === "string" ||
|
|
1181
|
+
if (t.type === "number" || t.type === "string" || t.type === "tmpl_start" ||
|
|
1100
1182
|
t.type === "binding" || t.type === "lparen" || t.type === "caret") return true;
|
|
1101
1183
|
return t.type === "op" && (t.value === "-" || t.value === "!");
|
|
1102
1184
|
}
|
|
@@ -1162,14 +1244,16 @@ class Parser {
|
|
|
1162
1244
|
// never places two operands side by side, so it cannot begin one here.
|
|
1163
1245
|
const bang = this.next();
|
|
1164
1246
|
expr = { kind: "required", span: [start, bang.end], expr };
|
|
1165
|
-
} else if (this.atValueDirective()) {
|
|
1247
|
+
} else if (this.atValueDirective() || this.at("lbrace") || (this.at("ident", "distinct") && this.peekAt(1)?.type === "lbrace")) {
|
|
1166
1248
|
// A value-position directive, `recv first { … }` — how a bracket chain
|
|
1167
|
-
// prints back
|
|
1168
|
-
// the
|
|
1169
|
-
|
|
1170
|
-
|
|
1249
|
+
// prints back — or the implicit `collect` of a bare `{ … }` / `distinct
|
|
1250
|
+
// { … }` (0.20: the receiver-block sugar stands wherever a value does,
|
|
1251
|
+
// `size(children { is checked })`). exists/none/count have no value form
|
|
1252
|
+
// and are left to the body's own error.
|
|
1253
|
+
if (expr.kind === "lit" || expr.kind === "template") this.fail(`\`${describe(expr)}\` is a literal, not a collection`);
|
|
1254
|
+
expr = this.parseConsumerBlock(expr, this.atBlock()!, false);
|
|
1171
1255
|
} else {
|
|
1172
|
-
break; //
|
|
1256
|
+
break; // anything else ends the chain
|
|
1173
1257
|
}
|
|
1174
1258
|
}
|
|
1175
1259
|
return expr;
|
|
@@ -1206,10 +1290,19 @@ class Parser {
|
|
|
1206
1290
|
}
|
|
1207
1291
|
if (t.type === "number") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: Number(t.value) }; }
|
|
1208
1292
|
if (t.type === "string") { this.next(); return { kind: "lit", span: [t.pos, t.end], value: t.value }; }
|
|
1293
|
+
if (t.type === "tmpl_start") return this.parseTemplateLiteral();
|
|
1209
1294
|
if (t.type === "binding") { this.next(); return this.bindingNode(t); }
|
|
1210
1295
|
if (t.type === "lparen") {
|
|
1211
1296
|
this.next();
|
|
1212
1297
|
const e = this.parseValueExpr();
|
|
1298
|
+
if (this.at("comma")) {
|
|
1299
|
+
// A tuple `(e1, e2, …)` (0.20): two or more elements.
|
|
1300
|
+
const items = [e];
|
|
1301
|
+
while (this.at("comma")) { this.next(); items.push(this.parseValueExpr()); }
|
|
1302
|
+
if (!this.at("rparen")) this.fail("expected ')' to close the tuple");
|
|
1303
|
+
this.next();
|
|
1304
|
+
return { kind: "tuple", span: [t.pos, this.end()], items };
|
|
1305
|
+
}
|
|
1213
1306
|
if (!this.at("rparen")) this.fail("expected ')'");
|
|
1214
1307
|
this.next();
|
|
1215
1308
|
return withSpan(e, [t.pos, this.end()]);
|
|
@@ -1224,6 +1317,39 @@ class Parser {
|
|
|
1224
1317
|
}
|
|
1225
1318
|
this.fail(`unexpected ${this.tokDesc()} — expected a value`);
|
|
1226
1319
|
}
|
|
1320
|
+
|
|
1321
|
+
// A template literal (GRAMMAR §4, since 0.21): the cursor is at the opening
|
|
1322
|
+
// backtick. The lexer has already split the literal into text runs and `${ … }`
|
|
1323
|
+
// interpolations (with balanced braces, so an interpolation may hold a block
|
|
1324
|
+
// or another template literal); each interpolation holds one full value
|
|
1325
|
+
// expression, read at the current scope depth. `parts` alternates strictly —
|
|
1326
|
+
// text, expression, text, … — with a (possibly empty) text part first and last.
|
|
1327
|
+
private parseTemplateLiteral(): Expr {
|
|
1328
|
+
const open = this.next(); // the backtick
|
|
1329
|
+
const parts: TemplatePart[] = [];
|
|
1330
|
+
let text = "";
|
|
1331
|
+
for (;;) {
|
|
1332
|
+
const t = this.peek();
|
|
1333
|
+
if (t.type === "tmpl_text") { this.next(); text += t.value; continue; }
|
|
1334
|
+
if (t.type === "tmpl_end") { this.next(); break; }
|
|
1335
|
+
if (t.type === "tmpl_interp_start") {
|
|
1336
|
+
this.next();
|
|
1337
|
+
if (this.at("tmpl_interp_end")) {
|
|
1338
|
+
this.failAt(t.pos, "empty interpolation `${}` in a template literal — an interpolation holds an expression (`${name}`)");
|
|
1339
|
+
}
|
|
1340
|
+
const e = this.parseValueExpr();
|
|
1341
|
+
if (!this.at("tmpl_interp_end")) this.fail("expected '}' to close the `${…}` interpolation of a template literal");
|
|
1342
|
+
this.next();
|
|
1343
|
+
parts.push(text, e);
|
|
1344
|
+
text = "";
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
// Unreachable: the lexer closes every literal it opens or fails.
|
|
1348
|
+
this.fail(`unexpected ${this.tokDesc()} in a template literal`);
|
|
1349
|
+
}
|
|
1350
|
+
parts.push(text);
|
|
1351
|
+
return { kind: "template", span: [open.pos, this.end()], parts };
|
|
1352
|
+
}
|
|
1227
1353
|
}
|
|
1228
1354
|
|
|
1229
1355
|
// The default key of an unaliased projection item: the last segment of a bare /
|