@omgbase/oqx 0.23.0 → 0.25.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 +12 -0
- package/README.md +71 -2
- package/dist/ast.d.ts +53 -1
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +7 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +9 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +11 -2
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts +4 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +188 -13
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +24 -1
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +8 -1
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +3 -0
- package/dist/optimize/ir.js.map +1 -1
- package/dist/optimize/rules.d.ts.map +1 -1
- package/dist/optimize/rules.js +5 -3
- package/dist/optimize/rules.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +467 -22
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +25 -0
- package/dist/print.js.map +1 -1
- package/dist/references.d.ts +6 -4
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +104 -4
- package/dist/references.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +27 -1
- 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 +1 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +15 -0
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +63 -2
- package/src/build.ts +13 -4
- package/src/engine.ts +186 -13
- package/src/index.ts +1 -1
- package/src/optimize/analysis.ts +18 -1
- package/src/optimize/ir.ts +11 -1
- package/src/optimize/rules.ts +5 -3
- package/src/parser.ts +387 -22
- package/src/print.ts +27 -3
- package/src/references.ts +95 -8
- package/src/resolve.ts +29 -2
- package/src/version.ts +1 -1
- package/src/walk.ts +18 -1
package/src/parser.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// consumer block — each clause appears at most once, in exactly this order:
|
|
14
14
|
//
|
|
15
15
|
// select <projection> from <source> where <predicate> follow <dest>, … {…}
|
|
16
|
-
// order by … limit N offset N
|
|
16
|
+
// flatten <expr> [{…}] [as <name>] … group by … having … order by … limit N offset N
|
|
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
|
|
@@ -83,8 +83,9 @@ import { lexTemplate, lexString } from "./lexer.ts";
|
|
|
83
83
|
import { OqxError } from "./errors.ts";
|
|
84
84
|
import type {
|
|
85
85
|
Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
|
|
86
|
-
BinaryOp, Span, TemplatePart,
|
|
86
|
+
BinaryOp, Span, TemplatePart, Flatten, GroupBy, GroupKey, AggregateName,
|
|
87
87
|
} from "./ast.ts";
|
|
88
|
+
import { AGGREGATE_NAMES, isAggregateName } from "./ast.ts";
|
|
88
89
|
import { resolveSelect, resolveWhere } from "./resolve.ts";
|
|
89
90
|
import { printTemplate } from "./print.ts";
|
|
90
91
|
|
|
@@ -93,8 +94,13 @@ const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first"
|
|
|
93
94
|
// `frontier` is a clause of every body that has a `follow` (a destination
|
|
94
95
|
// block's marks the occurrences it reaches, any other body's its seeds — 0.20);
|
|
95
96
|
// `depth` is legal only in a follow destination block, and the order sentence
|
|
96
|
-
// names it only there.
|
|
97
|
-
|
|
97
|
+
// names it only there. `group by` and `having` (0.24) sit after `follow`: the
|
|
98
|
+
// rows a body yields — the walk's occurrences, when it has one — are what gets
|
|
99
|
+
// grouped, and the bound and the ordering then apply to the groups. `flatten`
|
|
100
|
+
// (0.25) sits between them: it fans the rows a body yields — the occurrences,
|
|
101
|
+
// after a walk — out per element, and is the one clause that may repeat (each
|
|
102
|
+
// `flatten` fans out the rows the previous produced, SEMANTICS §25).
|
|
103
|
+
const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "flatten", "group by", "having", "order by", "limit", "offset"] as const;
|
|
98
104
|
type Clause = (typeof CLAUSE_ORDER)[number];
|
|
99
105
|
const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
|
|
100
106
|
// The clauses a body may hold, in order.
|
|
@@ -106,8 +112,11 @@ function clausesOf(ctx: BodyCtx): Clause[] {
|
|
|
106
112
|
const CLAUSE_WORDS = new Set<string>([
|
|
107
113
|
"collect", "exists", "none", "count", "first", "single",
|
|
108
114
|
"order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
|
|
109
|
-
"limit", "offset",
|
|
115
|
+
"limit", "offset", "group", "having", "flatten", "as",
|
|
110
116
|
]);
|
|
117
|
+
// The names a group scope resolves besides its keys (SEMANTICS §24): the
|
|
118
|
+
// group's rows, the key record itself, and its identity.
|
|
119
|
+
const GROUP_SCOPE_NAMES: ReadonlySet<string> = new Set(["rows", "$it", "$key"]);
|
|
111
120
|
const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
112
121
|
// The comparison level (GRAMMAR §4): the six relational operators and, since
|
|
113
122
|
// 0.22, the four regex operators — sugar over `matches`, non-associative like
|
|
@@ -152,6 +161,9 @@ interface BodyClauses {
|
|
|
152
161
|
follow: Follow | null;
|
|
153
162
|
frontier: Expr | null;
|
|
154
163
|
depth: number | null;
|
|
164
|
+
flatten: Flatten[];
|
|
165
|
+
groupBy: GroupBy | null;
|
|
166
|
+
having: Where | null;
|
|
155
167
|
distinct: boolean;
|
|
156
168
|
values: boolean;
|
|
157
169
|
limit: Expr | null;
|
|
@@ -265,6 +277,9 @@ class Parser {
|
|
|
265
277
|
consumer: "collect",
|
|
266
278
|
follow: body.follow,
|
|
267
279
|
frontier: body.frontier,
|
|
280
|
+
flatten: body.flatten,
|
|
281
|
+
groupBy: body.groupBy,
|
|
282
|
+
having: body.having,
|
|
268
283
|
distinct: body.distinct,
|
|
269
284
|
values: body.values,
|
|
270
285
|
limit: body.limit,
|
|
@@ -277,8 +292,8 @@ class Parser {
|
|
|
277
292
|
private queryFromOp(e: OpNode, start: number): Query {
|
|
278
293
|
return {
|
|
279
294
|
kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
|
|
280
|
-
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier,
|
|
281
|
-
limit: e.sub.limit, offset: e.sub.offset,
|
|
295
|
+
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, flatten: e.sub.flatten, groupBy: e.sub.groupBy, having: e.sub.having,
|
|
296
|
+
distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
|
|
282
297
|
};
|
|
283
298
|
}
|
|
284
299
|
|
|
@@ -300,6 +315,9 @@ class Parser {
|
|
|
300
315
|
let follow: Follow | null = null;
|
|
301
316
|
let frontier: Expr | null = null;
|
|
302
317
|
let depth: number | null = null;
|
|
318
|
+
const flatten: Flatten[] = [];
|
|
319
|
+
let groupBy: GroupBy | null = null;
|
|
320
|
+
let having: Where | null = null;
|
|
303
321
|
let distinct = false;
|
|
304
322
|
let values = false;
|
|
305
323
|
let limit: Expr | null = null;
|
|
@@ -312,7 +330,9 @@ class Parser {
|
|
|
312
330
|
|
|
313
331
|
const enter = (clause: Clause): void => {
|
|
314
332
|
const idx = CLAUSE_ORDER.indexOf(clause);
|
|
315
|
-
|
|
333
|
+
// `flatten` is the one clause that may repeat (0.25): each fans out the
|
|
334
|
+
// rows the previous produced.
|
|
335
|
+
if (idx === stage && clause !== "flatten") this.fail(`duplicate \`${clause}\` clause`);
|
|
316
336
|
if (idx < stage) {
|
|
317
337
|
this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
|
|
318
338
|
}
|
|
@@ -365,6 +385,28 @@ class Parser {
|
|
|
365
385
|
follow = this.atDepth(rowDepth, () => this.parseFollow());
|
|
366
386
|
continue;
|
|
367
387
|
}
|
|
388
|
+
// `flatten <expr> [{ … }] [as <name>]` (0.25): the value is read on each
|
|
389
|
+
// row, like `where`; a brace after a receiver is the receiver-block sugar
|
|
390
|
+
// (the `collect` op), so the block needs no slot of its own.
|
|
391
|
+
if (this.atFlatten()) {
|
|
392
|
+
enter("flatten");
|
|
393
|
+
flatten.push(this.atDepth(rowDepth, () => this.parseFlatten(flatten)));
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
// `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
|
|
397
|
+
if (this.atGroupBy()) {
|
|
398
|
+
enter("group by");
|
|
399
|
+
groupBy = this.atDepth(rowDepth, () => this.parseGroupBy());
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
// `having P` (0.24): a predicate over each GROUP, read at the row depth
|
|
403
|
+
// (the group scope sits where the rows did).
|
|
404
|
+
if (this.atHaving()) {
|
|
405
|
+
enter("having");
|
|
406
|
+
this.next();
|
|
407
|
+
having = this.atDepth(rowDepth, () => this.parseWhere());
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
368
410
|
if (this.atOrderBy()) {
|
|
369
411
|
enter("order by");
|
|
370
412
|
this.next(); this.next(); // `order` `by`
|
|
@@ -404,14 +446,255 @@ class Parser {
|
|
|
404
446
|
// Validate the alias references of this body (cycles, a block alias inside a
|
|
405
447
|
// `where` expression, a `select` item naming an item to its right); the
|
|
406
448
|
// surface form is kept. `where` first: its cycle report follows its own path.
|
|
407
|
-
|
|
408
|
-
|
|
449
|
+
// A grouped body without a projection yields its keys and a count (0.24);
|
|
450
|
+
// the items are synthesized before the alias validation sees the select.
|
|
451
|
+
if (groupBy && select.length === 0) select = defaultGroupProjection(groupBy, (msg) => this.failAt(groupBy!.span[0], msg));
|
|
452
|
+
const resolvedWhere = where ? resolveWhere(select, where, (msg) => this.fail(msg)) : null;
|
|
453
|
+
const resolvedHaving = having ? resolveWhere(select, having, (msg) => this.fail(msg)) : null;
|
|
454
|
+
// The resolved items: an aggregate reached through an alias (`n: count(),
|
|
455
|
+
// xs: collect(n)`) is judged where it will be evaluated.
|
|
456
|
+
const resolvedSelect = resolveSelect(select, (msg) => this.fail(msg));
|
|
409
457
|
// Outside a destination block `frontier` marks the seeds of this body's walk,
|
|
410
458
|
// so it needs one.
|
|
411
459
|
if (frontier && !follow && !ctx.destination) {
|
|
412
460
|
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");
|
|
413
461
|
}
|
|
414
|
-
|
|
462
|
+
// Grouping (0.24): `having` needs a `group by`; `distinct` is redundant with
|
|
463
|
+
// one; aggregates may stand only in the post-group clauses; and every bare
|
|
464
|
+
// name read at the group scope must be a key, an alias, or the group's rows.
|
|
465
|
+
if (having && !groupBy) {
|
|
466
|
+
this.failAt(having.span[0], "`having` needs a `group by` — `having <pred>` filters the groups a body yields; a per-row filter is `where`");
|
|
467
|
+
}
|
|
468
|
+
if (groupBy) {
|
|
469
|
+
if (distinct) this.failAt(groupBy.span[0], DISTINCT_WITH_GROUP);
|
|
470
|
+
this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy });
|
|
471
|
+
} else {
|
|
472
|
+
this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy: null });
|
|
473
|
+
}
|
|
474
|
+
return { froms, where, select, orderBy, follow, frontier, depth, flatten, groupBy, having, distinct, values, limit, offset };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---- flatten (0.25) -------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
// `flatten` is a clause when a value can follow the word; otherwise it is a
|
|
480
|
+
// field name (`select flatten from r`).
|
|
481
|
+
private atFlatten(): boolean {
|
|
482
|
+
if (!this.at("ident", "flatten")) return false;
|
|
483
|
+
const nx = this.peekAt(1);
|
|
484
|
+
// A value start — or an aggregate call whose name is a consumer word
|
|
485
|
+
// (`flatten collect(x)`), admitted so the aggregate check can name it.
|
|
486
|
+
return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// `flatten <expr> [as <name>]`. The expression is any value — a block after a
|
|
490
|
+
// receiver is read by the postfix grammar as the implicit `collect` op (0.20),
|
|
491
|
+
// which is exactly the block over the coerced collection SEMANTICS §25 wants.
|
|
492
|
+
// The name defaults to a plain navigation's last segment (as a select item's
|
|
493
|
+
// or a group key's does); anything else needs `as`. Names are unique within
|
|
494
|
+
// the body, never a `$`-name (the language's), a keyword or a literal word.
|
|
495
|
+
private parseFlatten(earlier: readonly Flatten[]): Flatten {
|
|
496
|
+
const start = this.start();
|
|
497
|
+
this.next(); // `flatten`
|
|
498
|
+
const expr = this.parseValueExpr();
|
|
499
|
+
let name: string;
|
|
500
|
+
if (this.at("ident", "as")) {
|
|
501
|
+
this.next();
|
|
502
|
+
const t = this.peek();
|
|
503
|
+
if (t.type !== "ident") this.fail("expected a name after `as` (`flatten <expr> as <name>`)");
|
|
504
|
+
if (LITERAL_WORDS.has(t.value)) this.fail(`\`${t.value}\` is a literal and cannot name a flattened value (\`flatten <expr> as <name>\`)`);
|
|
505
|
+
if (t.value.startsWith("$")) this.fail(`a flattened value cannot be bound to a \`$\`-name (\`${t.value}\`) — \`$\` belongs to the language; choose another name`);
|
|
506
|
+
name = this.next().value;
|
|
507
|
+
} else {
|
|
508
|
+
const key = navKey(expr);
|
|
509
|
+
if (key === null) {
|
|
510
|
+
this.failAt(start, `\`flatten ${describe(expr)}\` needs a name (\`flatten ${describe(expr)} as <name>\`) — only a plain navigation names itself by its last segment`);
|
|
511
|
+
}
|
|
512
|
+
name = key;
|
|
513
|
+
}
|
|
514
|
+
if (earlier.some((f) => f.name === name)) this.failAt(start, `duplicate flatten name '${name}' — each flatten binds its own name (\`flatten <expr> as <other>\`)`);
|
|
515
|
+
return { kind: "flatten", span: this.sp(start), expr, name };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---- grouping (0.24) ------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
// `group by` is a clause only as the two-word sequence (a field named `group`
|
|
521
|
+
// stays a name: `select group from r`).
|
|
522
|
+
private atGroupBy(): boolean {
|
|
523
|
+
const t = this.peek();
|
|
524
|
+
const nx = this.peekAt(1);
|
|
525
|
+
return t.type === "ident" && t.value === "group" && !!nx && nx.type === "ident" && nx.value === "by";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// `having` is a clause when a predicate can follow it — a value start, or an
|
|
529
|
+
// aggregate call whose name is a consumer word (`having count() > 1`:
|
|
530
|
+
// `count` alone cannot begin a value, `count(` can); otherwise a field name.
|
|
531
|
+
private atHaving(): boolean {
|
|
532
|
+
if (!this.at("ident", "having")) return false;
|
|
533
|
+
const nx = this.peekAt(1);
|
|
534
|
+
return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private parseGroupBy(): GroupBy {
|
|
538
|
+
const start = this.start();
|
|
539
|
+
this.next(); this.next(); // `group` `by`
|
|
540
|
+
const keys = [this.parseGroupKey()];
|
|
541
|
+
while (this.at("comma")) { this.next(); keys.push(this.parseGroupKey()); }
|
|
542
|
+
const seen = new Set<string>();
|
|
543
|
+
for (const k of keys) {
|
|
544
|
+
if (seen.has(k.name)) this.failAt(k.span[0], `duplicate group key '${k.name}' — each key needs its own name (alias one: \`other: expr\`)`);
|
|
545
|
+
seen.add(k.name);
|
|
546
|
+
}
|
|
547
|
+
return { kind: "groupBy", span: this.sp(start), keys };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// One key, shaped like a select item: `name: expr`, or an unaliased navigation
|
|
551
|
+
// keyed by its last segment; any other expression needs the alias, because the
|
|
552
|
+
// alias is how the post-group clauses refer to the key.
|
|
553
|
+
private parseGroupKey(): GroupKey {
|
|
554
|
+
const start = this.start();
|
|
555
|
+
if (this.at("ident") && this.peekAt(1)?.type === "colon") {
|
|
556
|
+
const name = this.next().value;
|
|
557
|
+
this.next(); // ':'
|
|
558
|
+
const expr = this.parseValueExpr();
|
|
559
|
+
return { kind: "key", span: this.sp(start), name, expr };
|
|
560
|
+
}
|
|
561
|
+
if (!this.canStartValue()) this.fail("expected a group key after `group by` (`group by <name>` or `group by <alias>: <expr>`)");
|
|
562
|
+
const expr = this.parseValueExpr();
|
|
563
|
+
const name = navKey(expr);
|
|
564
|
+
if (name === null) {
|
|
565
|
+
this.failAt(start, `a group key that is not a plain name needs an alias (\`group by key: ${describe(expr)}\`) — the alias is the name the select, \`having\` and \`order by\` read it by`);
|
|
566
|
+
}
|
|
567
|
+
return { kind: "key", span: this.sp(start), name, expr };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// `name(` where `name` is an aggregate: `count()`, `sum(e)`, `collect(distinct
|
|
571
|
+
// e)`. The cursor is at the `(`. Arity and the `distinct` modifier are checked
|
|
572
|
+
// here; WHERE the aggregate may stand is the body's business (`checkGroupedBody`).
|
|
573
|
+
private parseAggregate(name: AggregateName, start: number): Expr {
|
|
574
|
+
this.next(); // '('
|
|
575
|
+
let distinct = false;
|
|
576
|
+
if (this.at("ident", "distinct") && this.startsValue(this.peekAt(1))) { this.next(); distinct = true; }
|
|
577
|
+
let arg: Expr | null = null;
|
|
578
|
+
if (!this.at("rparen")) {
|
|
579
|
+
arg = this.parseValueExpr();
|
|
580
|
+
if (this.at("comma")) this.fail(`\`${name}(…)\` takes one argument — an aggregate reduces one expression over the group's rows`);
|
|
581
|
+
}
|
|
582
|
+
if (!this.at("rparen")) this.fail(`expected ')' to close \`${name}(…)\``);
|
|
583
|
+
this.next();
|
|
584
|
+
if (arg === null && name !== "count") this.fail(`\`${name}()\` needs an argument (\`${name}(<expr>)\`) — only \`count()\` counts the group's rows without one`);
|
|
585
|
+
if (distinct && (name === "any" || name === "all")) {
|
|
586
|
+
this.fail(`\`${name}(distinct …)\`: \`distinct\` applies to a value aggregate (count, sum, min, max, avg, collect, first, last), not to a predicate aggregate`);
|
|
587
|
+
}
|
|
588
|
+
return { kind: "aggregate", span: [start, this.end()], name, arg, distinct };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// A free call: an aggregate node for an aggregate name (reserved since 0.24),
|
|
592
|
+
// an ordinary `call` otherwise. The cursor is at the `(`.
|
|
593
|
+
private parseFreeCall(name: string, start: number): Expr {
|
|
594
|
+
if (isAggregateName(name)) return this.parseAggregate(name, start);
|
|
595
|
+
const args = this.parseArgs();
|
|
596
|
+
return { kind: "call", span: this.sp(start), recv: null, name, args };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// The clauses an aggregate may NOT stand in — every clause read before the
|
|
600
|
+
// rows are grouped (`from`, `where`, `frontier`, the keys, the bound) — and,
|
|
601
|
+
// in a body without `group by`, every clause. The `where` is the alias-resolved
|
|
602
|
+
// one, so an aggregate reached through a select alias is caught too.
|
|
603
|
+
private rejectAggregates(b: GroupCheck): void {
|
|
604
|
+
const found: [Expr, string][] = [];
|
|
605
|
+
const collect = (exprs: (Expr | null)[], clause: string): void => {
|
|
606
|
+
const out: Expr[] = [];
|
|
607
|
+
for (const e of exprs) if (e) aggregatesIn(e, out);
|
|
608
|
+
for (const a of out) found.push([a, clause]);
|
|
609
|
+
};
|
|
610
|
+
const where: Expr[] = [];
|
|
611
|
+
if (b.where) whereAggregates(b.where, where);
|
|
612
|
+
for (const a of where) found.push([a, "`where`"]);
|
|
613
|
+
collect(b.froms, "`from`");
|
|
614
|
+
collect([b.frontier], "`frontier`");
|
|
615
|
+
collect(b.flatten.map((f) => f.expr), "`flatten`");
|
|
616
|
+
collect([b.limit], "`limit`");
|
|
617
|
+
collect([b.offset], "`offset`");
|
|
618
|
+
if (b.groupBy) collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
|
|
619
|
+
if (!b.groupBy) {
|
|
620
|
+
collect(b.resolvedSelect.filter((it) => it.kind === "field").map((it) => (it as Extract<SelectItem, { kind: "field" }>).expr), "a select item");
|
|
621
|
+
if (b.orderBy) collect(b.orderBy.map((o) => o.expr), "`order by`");
|
|
622
|
+
}
|
|
623
|
+
const hit = found[0];
|
|
624
|
+
if (!hit) return;
|
|
625
|
+
const [agg, clause] = hit;
|
|
626
|
+
if (!b.groupBy) {
|
|
627
|
+
this.failAt(agg.span[0], `\`${describe(agg)}\` is an aggregate: it is read after grouping and may stand only in a select item, \`having\` or \`order by\` of a body with \`group by\` — this body has none`);
|
|
628
|
+
}
|
|
629
|
+
this.failAt(agg.span[0], `\`${describe(agg)}\` is an aggregate and cannot be read in ${clause}${clause === "`where`" ? " (a select alias standing for one included)" : ""} — the rows are not grouped yet there; aggregates stand in a select item, \`having\` or \`order by\``);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// A grouped body: aggregates only after the grouping (and never nested), and
|
|
633
|
+
// every bare name at the group scope a key, an alias, or the group's rows.
|
|
634
|
+
private checkGroupedBody(b: GroupCheck): void {
|
|
635
|
+
this.rejectAggregates(b);
|
|
636
|
+
// An aggregate's argument is read on the group's rows, where there is no
|
|
637
|
+
// group to aggregate: `sum(count())` is an error, not a double reduction.
|
|
638
|
+
const post: Expr[] = [];
|
|
639
|
+
for (const it of b.resolvedSelect) if (it.kind === "field") aggregatesIn(it.expr, post);
|
|
640
|
+
if (b.resolvedHaving) whereAggregates(b.resolvedHaving, post);
|
|
641
|
+
if (b.orderBy) for (const o of b.orderBy) aggregatesIn(o.expr, post);
|
|
642
|
+
const nested = nestedAggregate(post);
|
|
643
|
+
if (nested) this.failAt(nested.span[0], `\`${describe(nested)}\`: an aggregate cannot nest — its argument is read on each of the group's rows, where there is no group to reduce`);
|
|
644
|
+
const keys = new Set(b.groupBy!.keys.map((k) => k.name));
|
|
645
|
+
const valid = (aliases: Iterable<string>): ReadonlySet<string> => new Set([...keys, ...GROUP_SCOPE_NAMES, ...aliases]);
|
|
646
|
+
const aliases: string[] = [];
|
|
647
|
+
for (const it of b.select) {
|
|
648
|
+
const names = valid(aliases);
|
|
649
|
+
if (it.kind === "field") this.checkGroupExpr(it.expr, names, "a select item");
|
|
650
|
+
else this.checkGroupExpr(it.op.receiver, names, "a select item");
|
|
651
|
+
const own = it.kind === "collect" ? (it.name === "" ? null : it.name) : it.lift === 0 && it.name !== "" ? it.name : null;
|
|
652
|
+
if (own !== null) aliases.push(own);
|
|
653
|
+
}
|
|
654
|
+
if (b.having) this.checkGroupWhere(b.having, valid(aliases), "`having`");
|
|
655
|
+
if (b.orderBy) for (const o of b.orderBy) this.checkGroupExpr(o.expr, valid([]), "`order by`");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private checkGroupWhere(w: Where, valid: ReadonlySet<string>, where: string): void {
|
|
659
|
+
switch (w.kind) {
|
|
660
|
+
case "and": case "or": w.parts.forEach((p) => this.checkGroupWhere(p, valid, where)); return;
|
|
661
|
+
case "not": this.checkGroupWhere(w.expr, valid, where); return;
|
|
662
|
+
case "scalar": this.checkGroupExpr(w.expr, valid, where); return;
|
|
663
|
+
case "op": this.checkGroupExpr(w.receiver, valid, where); return; // the block is its own scope
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Every bare name `e` reads at the group scope (an aggregate's argument reads
|
|
668
|
+
// the group's rows and is not checked; a nested block is its own scope, only
|
|
669
|
+
// its receiver is read here). `inCall` names the enclosing non-aggregate free
|
|
670
|
+
// call, for the "not an aggregate" hint.
|
|
671
|
+
private checkGroupExpr(e: Expr, valid: ReadonlySet<string>, where: string, inCall: string | null = null): void {
|
|
672
|
+
switch (e.kind) {
|
|
673
|
+
case "ident":
|
|
674
|
+
if (!valid.has(e.name)) {
|
|
675
|
+
const hint = inCall === null ? "" : `; \`${inCall}(…)\` is not an aggregate — the aggregates are ${[...AGGREGATE_NAMES].join(", ")}`;
|
|
676
|
+
this.failAt(e.span[0], `\`${e.name}\` is not a group key or aggregate — ${where} of a grouped body reads the group: a key (\`group by ${e.name}\`), an aggregate over its rows (\`collect(${e.name})\`), or the rows themselves (\`rows collect { select ${e.name} }\`)${hint}`);
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
case "lit": case "binding": case "outer": case "aggregate": return;
|
|
680
|
+
case "member": this.checkGroupExpr(e.recv, valid, where, inCall); return;
|
|
681
|
+
case "call":
|
|
682
|
+
if (e.recv) this.checkGroupExpr(e.recv, valid, where, inCall);
|
|
683
|
+
for (const a of e.args) this.checkGroupExpr(a, valid, where, e.recv === null ? e.name : inCall);
|
|
684
|
+
return;
|
|
685
|
+
case "unary": case "required": this.checkGroupExpr(e.expr, valid, where, inCall); return;
|
|
686
|
+
case "binary": case "logical": case "in":
|
|
687
|
+
this.checkGroupExpr(e.left, valid, where, inCall);
|
|
688
|
+
this.checkGroupExpr(e.right, valid, where, inCall);
|
|
689
|
+
return;
|
|
690
|
+
case "range":
|
|
691
|
+
if (e.lo) this.checkGroupExpr(e.lo, valid, where, inCall);
|
|
692
|
+
if (e.hi) this.checkGroupExpr(e.hi, valid, where, inCall);
|
|
693
|
+
return;
|
|
694
|
+
case "tuple": e.items.forEach((x) => this.checkGroupExpr(x, valid, where, inCall)); return;
|
|
695
|
+
case "template": e.parts.forEach((p) => { if (typeof p !== "string") this.checkGroupExpr(p, valid, where, inCall); }); return;
|
|
696
|
+
case "op": this.checkGroupExpr(e.receiver, valid, where, inCall); return;
|
|
697
|
+
}
|
|
415
698
|
}
|
|
416
699
|
|
|
417
700
|
// The keyword-less run that opens a body. At the top level it is always the
|
|
@@ -488,6 +771,9 @@ class Parser {
|
|
|
488
771
|
}
|
|
489
772
|
if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
|
|
490
773
|
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\``);
|
|
774
|
+
if (t.value === "flatten") this.fail(`unexpected 'flatten' after \`${last}\` — \`flatten\` takes a value to split into rows (\`flatten <expr> [{ … }] [as <name>]\`)`);
|
|
775
|
+
if (t.value === "group") this.fail(`unexpected 'group' after \`${last}\` — a grouping is written \`group by <key>[, <key>…]\` (a key is a name or \`alias: <expr>\`)`);
|
|
776
|
+
if (t.value === "having") this.fail(`unexpected 'having' after \`${last}\` — \`having\` takes a predicate over each group (\`having count() > 1\`) and belongs to a body that has a \`group by\``);
|
|
491
777
|
}
|
|
492
778
|
if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
|
|
493
779
|
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\``);
|
|
@@ -659,10 +945,7 @@ class Parser {
|
|
|
659
945
|
? { kind: "outer", span: [start, head.end], levels, name: head.value }
|
|
660
946
|
: { kind: "ident", span: [start, head.end], name: head.value };
|
|
661
947
|
let name = head.value;
|
|
662
|
-
if (levels === 0 && this.at("lparen"))
|
|
663
|
-
const args = this.parseArgs();
|
|
664
|
-
expr = { kind: "call", span: this.sp(start), recv: null, name: head.value, args };
|
|
665
|
-
}
|
|
948
|
+
if (levels === 0 && this.at("lparen")) expr = this.parseFreeCall(head.value, start);
|
|
666
949
|
while (this.at("dot")) {
|
|
667
950
|
this.next();
|
|
668
951
|
if (!this.at("ident")) this.fail("expected a property name after '.'");
|
|
@@ -728,8 +1011,8 @@ class Parser {
|
|
|
728
1011
|
if (op) {
|
|
729
1012
|
// `name: jobs first { }.pay`: the directive is the head of a longer value.
|
|
730
1013
|
if (this.atScalarContinuation() || this.atValueDirective()) return this.finishItem(name, this.parseOr(op), lift, start);
|
|
731
|
-
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
732
|
-
this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none
|
|
1014
|
+
if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
|
|
1015
|
+
this.fail(`projection '${name}' must use collect/first/single/count, not ${op.op} (exists/none are where-position tests; \`count { … }\` is a value since 0.24)`);
|
|
733
1016
|
}
|
|
734
1017
|
return this.finishItem(name, op, lift, start);
|
|
735
1018
|
}
|
|
@@ -1010,6 +1293,9 @@ class Parser {
|
|
|
1010
1293
|
this.deferLookup = defer;
|
|
1011
1294
|
if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
|
|
1012
1295
|
this.next();
|
|
1296
|
+
// `x collect distinct { group by k }`: the directive's distinct is as
|
|
1297
|
+
// redundant as the body's own (checked in parseBody).
|
|
1298
|
+
if (distinct && body.groupBy) this.failAt(body.groupBy.span[0], DISTINCT_WITH_GROUP);
|
|
1013
1299
|
const sub: Subquery = {
|
|
1014
1300
|
kind: "subquery",
|
|
1015
1301
|
span: this.sp(lbrace),
|
|
@@ -1020,6 +1306,9 @@ class Parser {
|
|
|
1020
1306
|
follow: body.follow,
|
|
1021
1307
|
frontier: body.frontier,
|
|
1022
1308
|
depth: body.depth,
|
|
1309
|
+
flatten: body.flatten,
|
|
1310
|
+
groupBy: body.groupBy,
|
|
1311
|
+
having: body.having,
|
|
1023
1312
|
values: body.values,
|
|
1024
1313
|
limit: body.limit,
|
|
1025
1314
|
offset: body.offset,
|
|
@@ -1078,7 +1367,7 @@ class Parser {
|
|
|
1078
1367
|
this.next();
|
|
1079
1368
|
const subSpan: Span = [lbracket, this.end()];
|
|
1080
1369
|
const sub = (parts: Partial<Subquery>): Subquery => ({
|
|
1081
|
-
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
|
|
1370
|
+
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, flatten: [], groupBy: null, having: null, values: false, limit: null, offset: null, ...parts,
|
|
1082
1371
|
});
|
|
1083
1372
|
const bang = this.atOp("!") ? this.next() : null;
|
|
1084
1373
|
if (index) {
|
|
@@ -1237,9 +1526,9 @@ class Parser {
|
|
|
1237
1526
|
expr = { kind: "member", span: this.sp(start), recv: expr, name };
|
|
1238
1527
|
}
|
|
1239
1528
|
} else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
|
|
1240
|
-
// free function call: name(args) — a bare name read here, not `(f)(x)
|
|
1241
|
-
|
|
1242
|
-
expr =
|
|
1529
|
+
// free function call: name(args) — a bare name read here, not `(f)(x)`;
|
|
1530
|
+
// an aggregate name makes it an `aggregate` node (0.24)
|
|
1531
|
+
expr = this.parseFreeCall(expr.name, start);
|
|
1243
1532
|
} else if (this.at("lbracket")) {
|
|
1244
1533
|
expr = this.parseBracket(expr);
|
|
1245
1534
|
} else if (this.atOp("!")) {
|
|
@@ -1262,9 +1551,12 @@ class Parser {
|
|
|
1262
1551
|
return expr;
|
|
1263
1552
|
}
|
|
1264
1553
|
|
|
1554
|
+
// The directives that have a value form: collect/first/single, and since 0.24
|
|
1555
|
+
// `count { … }` (the number of rows the block yields). exists/none are
|
|
1556
|
+
// predicates only.
|
|
1265
1557
|
private atValueDirective(): boolean {
|
|
1266
1558
|
const op = this.atConsumerBlock();
|
|
1267
|
-
return op === "collect" || op === "first" || op === "single";
|
|
1559
|
+
return op === "collect" || op === "first" || op === "single" || op === "count";
|
|
1268
1560
|
}
|
|
1269
1561
|
|
|
1270
1562
|
private parseArgs(): Expr[] {
|
|
@@ -1355,6 +1647,79 @@ class Parser {
|
|
|
1355
1647
|
}
|
|
1356
1648
|
}
|
|
1357
1649
|
|
|
1650
|
+
// ---- grouping helpers (0.24) --------------------------------------------------
|
|
1651
|
+
|
|
1652
|
+
const DISTINCT_WITH_GROUP = "`distinct` is redundant with `group by` — a grouped body already yields one row per distinct key tuple; drop `distinct`";
|
|
1653
|
+
|
|
1654
|
+
// The clauses `checkGroupedBody` / `rejectAggregates` look at: the body's own
|
|
1655
|
+
// expressions, with `where` (and `resolvedHaving`) already alias-resolved.
|
|
1656
|
+
interface GroupCheck {
|
|
1657
|
+
select: SelectItem[];
|
|
1658
|
+
/** The items with their aliases substituted (`resolveSelect`), for the aggregate checks. */
|
|
1659
|
+
resolvedSelect: SelectItem[];
|
|
1660
|
+
having: Where | null;
|
|
1661
|
+
resolvedHaving: Where | null;
|
|
1662
|
+
orderBy: OrderSpec[] | null;
|
|
1663
|
+
froms: Expr[];
|
|
1664
|
+
where: Where | null;
|
|
1665
|
+
frontier: Expr | null;
|
|
1666
|
+
flatten: readonly Flatten[];
|
|
1667
|
+
limit: Expr | null;
|
|
1668
|
+
offset: Expr | null;
|
|
1669
|
+
groupBy: GroupBy | null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// The projection of a grouped body that writes none (SEMANTICS §24): every key
|
|
1673
|
+
// by its name, then `count: count()`. Synthesized here so the tree shows what
|
|
1674
|
+
// the body projects and the engine has no grouped "row itself" to define; the
|
|
1675
|
+
// nodes carry the empty span (they did not come from source).
|
|
1676
|
+
function defaultGroupProjection(groupBy: GroupBy, fail: (msg: string) => never): SelectItem[] {
|
|
1677
|
+
if (groupBy.keys.some((k) => k.name === "count")) {
|
|
1678
|
+
fail("a group key named `count` needs an explicit `select` — the default projection of a grouped body is its keys plus `count: count()`");
|
|
1679
|
+
}
|
|
1680
|
+
const items: SelectItem[] = groupBy.keys.map((k) => ({ kind: "field", span: [0, 0], name: k.name, expr: { kind: "ident", span: [0, 0], name: k.name }, lift: 0 }));
|
|
1681
|
+
items.push({ kind: "field", span: [0, 0], name: "count", expr: { kind: "aggregate", span: [0, 0], name: "count", arg: null, distinct: false }, lift: 0 });
|
|
1682
|
+
return items;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// Every aggregate node in `e` read at THIS scope (a nested block's body is its
|
|
1686
|
+
// own scope and is not entered; a block's receiver is). An aggregate's argument
|
|
1687
|
+
// is entered too, so a nested aggregate is found by `nestedAggregate`.
|
|
1688
|
+
function aggregatesIn(e: Expr, out: Expr[]): void {
|
|
1689
|
+
switch (e.kind) {
|
|
1690
|
+
case "aggregate": out.push(e); if (e.arg) aggregatesIn(e.arg, out); return;
|
|
1691
|
+
case "lit": case "ident": case "outer": case "binding": return;
|
|
1692
|
+
case "member": aggregatesIn(e.recv, out); return;
|
|
1693
|
+
case "call": if (e.recv) aggregatesIn(e.recv, out); e.args.forEach((a) => aggregatesIn(a, out)); return;
|
|
1694
|
+
case "unary": case "required": aggregatesIn(e.expr, out); return;
|
|
1695
|
+
case "binary": case "logical": case "in": aggregatesIn(e.left, out); aggregatesIn(e.right, out); return;
|
|
1696
|
+
case "range": if (e.lo) aggregatesIn(e.lo, out); if (e.hi) aggregatesIn(e.hi, out); return;
|
|
1697
|
+
case "tuple": e.items.forEach((x) => aggregatesIn(x, out)); return;
|
|
1698
|
+
case "template": e.parts.forEach((p) => { if (typeof p !== "string") aggregatesIn(p, out); }); return;
|
|
1699
|
+
case "op": aggregatesIn(e.receiver, out); return;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function whereAggregates(w: Where, out: Expr[]): void {
|
|
1704
|
+
switch (w.kind) {
|
|
1705
|
+
case "and": case "or": w.parts.forEach((p) => whereAggregates(p, out)); return;
|
|
1706
|
+
case "not": whereAggregates(w.expr, out); return;
|
|
1707
|
+
case "scalar": aggregatesIn(w.expr, out); return;
|
|
1708
|
+
case "op": aggregatesIn(w.receiver, out); return;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// The first aggregate whose argument holds another (`sum(count())`), if any.
|
|
1713
|
+
function nestedAggregate(found: readonly Expr[]): Expr | null {
|
|
1714
|
+
for (const a of found) {
|
|
1715
|
+
if (a.kind !== "aggregate" || !a.arg) continue;
|
|
1716
|
+
const inner: Expr[] = [];
|
|
1717
|
+
aggregatesIn(a.arg, inner);
|
|
1718
|
+
if (inner.length > 0) return a;
|
|
1719
|
+
}
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1358
1723
|
// The default key of an unaliased projection item: the last segment of a bare /
|
|
1359
1724
|
// dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
|
|
1360
1725
|
function navKey(e: Expr): string | null {
|
package/src/print.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// binding is a value, never source text: `print` throws on one; `printTemplate`
|
|
13
13
|
// emits template fragments around each binding instead.
|
|
14
14
|
|
|
15
|
-
import type { AstNode, Expr, Follow, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
|
|
15
|
+
import type { AstNode, Expr, Flatten, Follow, GroupBy, GroupKey, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
|
|
16
16
|
import { OqxError } from "./errors.ts";
|
|
17
17
|
|
|
18
18
|
/** The template form of a tree: `strings` has `count + 1` fragments; the i-th gap
|
|
@@ -67,7 +67,7 @@ const OR = 1, AND = 2, CMP = 3, RANGE = 4, ADD = 5, MUL = 6, UNARY = 7, POSTFIX
|
|
|
67
67
|
function prec(e: Expr): number {
|
|
68
68
|
switch (e.kind) {
|
|
69
69
|
case "lit": case "ident": case "outer": case "binding": case "tuple": case "template": return PRIMARY;
|
|
70
|
-
case "member": case "call": case "required": case "op": return POSTFIX;
|
|
70
|
+
case "member": case "call": case "aggregate": case "required": case "op": return POSTFIX;
|
|
71
71
|
case "unary": return UNARY;
|
|
72
72
|
// Comparison, identity and the regex operators (0.22) share the cmp level.
|
|
73
73
|
case "binary": return e.op === "+" || e.op === "-" ? ADD : e.op === "*" || e.op === "/" || e.op === "%" ? MUL : CMP;
|
|
@@ -83,6 +83,9 @@ function render(n: AstNode): Out {
|
|
|
83
83
|
case "subquery": return block(n);
|
|
84
84
|
case "op": return op(n);
|
|
85
85
|
case "follow": return follow(n);
|
|
86
|
+
case "flatten": return flatten(n);
|
|
87
|
+
case "groupBy": return groupBy(n);
|
|
88
|
+
case "key": return key(n);
|
|
86
89
|
case "order": return order(n);
|
|
87
90
|
case "field": case "collect": return item(n);
|
|
88
91
|
case "and": case "or": case "not": case "scalar": return where(n);
|
|
@@ -115,7 +118,7 @@ function query(q: Query): Out {
|
|
|
115
118
|
return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "orderBy" | "limit" | "offset">
|
|
121
|
+
type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "flatten" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
|
|
119
122
|
& Partial<Pick<Subquery, "frontier" | "depth">>;
|
|
120
123
|
|
|
121
124
|
// The clauses after `from` (a query) or after the projection (a block): the
|
|
@@ -128,6 +131,9 @@ function tail(b: Omit<Body, "select" | "values">): Out[] {
|
|
|
128
131
|
if (b.frontier) out.push(cat("frontier ", expr(b.frontier)));
|
|
129
132
|
if (b.depth != null) out.push([`depth ${number(b.depth)}`]);
|
|
130
133
|
if (b.follow) out.push(follow(b.follow));
|
|
134
|
+
for (const f of b.flatten) out.push(flatten(f));
|
|
135
|
+
if (b.groupBy) out.push(groupBy(b.groupBy));
|
|
136
|
+
if (b.having) out.push(cat("having ", where(b.having)));
|
|
131
137
|
if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
|
|
132
138
|
if (b.limit) out.push(cat("limit ", expr(b.limit)));
|
|
133
139
|
if (b.offset) out.push(cat("offset ", expr(b.offset)));
|
|
@@ -179,6 +185,22 @@ function follow(f: Follow): Out {
|
|
|
179
185
|
return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
|
|
180
186
|
}
|
|
181
187
|
|
|
188
|
+
// `as <name>` is always written (0.25), as every select item and group key is
|
|
189
|
+
// aliased: the tree does not record whether it was, and the name is what the
|
|
190
|
+
// later clauses read.
|
|
191
|
+
function flatten(f: Flatten): Out {
|
|
192
|
+
return cat("flatten ", expr(f.expr), ` as ${f.name}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Every key aliased, as every select item is (`layer: layer`).
|
|
196
|
+
function groupBy(g: GroupBy): Out {
|
|
197
|
+
return cat("group by ", join(g.keys.map(key), ", "));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function key(k: GroupKey): Out {
|
|
201
|
+
return cat(`${k.name}: `, expr(k.expr));
|
|
202
|
+
}
|
|
203
|
+
|
|
182
204
|
// ---- where ------------------------------------------------------------------
|
|
183
205
|
|
|
184
206
|
function where(w: Where): Out {
|
|
@@ -212,6 +234,8 @@ function expr(e: Expr): Out {
|
|
|
212
234
|
const args = cat("(", join(e.args.map(expr), ", "), ")");
|
|
213
235
|
return e.recv === null ? cat(e.name, args) : cat(receiver(e.recv), `.${e.name}`, args);
|
|
214
236
|
}
|
|
237
|
+
// An aggregate prints as the call it is written as (`count()`, `sum(distinct x)`).
|
|
238
|
+
case "aggregate": return cat(e.name, "(", e.distinct ? "distinct " : "", e.arg ? expr(e.arg) : "", ")");
|
|
215
239
|
case "unary": return cat(e.op, operand(e.expr, UNARY));
|
|
216
240
|
// Postfix, tightest: anything below postfix level is grouped (`(a + 1)!`).
|
|
217
241
|
case "required": return cat(operand(e.expr, POSTFIX), "!");
|