@omgbase/oqx 0.21.0 → 0.24.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 +17 -0
- package/README.md +79 -2
- package/dist/ast.d.ts +48 -3
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js +17 -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 +9 -2
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts +3 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +153 -11
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +3 -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.map +1 -1
- package/dist/lexer.js +13 -3
- package/dist/lexer.js.map +1 -1
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +19 -2
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +5 -1
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +2 -0
- package/dist/optimize/ir.js.map +1 -1
- package/dist/optimize/rules.d.ts.map +1 -1
- package/dist/optimize/rules.js +4 -3
- package/dist/optimize/rules.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +406 -21
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +17 -0
- package/dist/print.js.map +1 -1
- package/dist/references.d.ts +28 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +287 -0
- package/dist/references.js.map +1 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +20 -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 +10 -0
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +64 -4
- package/src/build.ts +11 -4
- package/src/engine.ts +154 -11
- package/src/index.ts +3 -1
- package/src/lexer.ts +10 -4
- package/src/optimize/analysis.ts +17 -2
- package/src/optimize/ir.ts +7 -1
- package/src/optimize/rules.ts +4 -3
- package/src/parser.ts +328 -21
- package/src/print.ts +19 -3
- package/src/references.ts +293 -0
- package/src/resolve.ts +22 -2
- package/src/version.ts +1 -1
- package/src/walk.ts +12 -1
package/src/parser.ts
CHANGED
|
@@ -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, 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,10 @@ 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.
|
|
100
|
+
const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "group by", "having", "order by", "limit", "offset"] as const;
|
|
98
101
|
type Clause = (typeof CLAUSE_ORDER)[number];
|
|
99
102
|
const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
|
|
100
103
|
// The clauses a body may hold, in order.
|
|
@@ -106,10 +109,16 @@ function clausesOf(ctx: BodyCtx): Clause[] {
|
|
|
106
109
|
const CLAUSE_WORDS = new Set<string>([
|
|
107
110
|
"collect", "exists", "none", "count", "first", "single",
|
|
108
111
|
"order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
|
|
109
|
-
"limit", "offset",
|
|
112
|
+
"limit", "offset", "group", "having",
|
|
110
113
|
]);
|
|
114
|
+
// The names a group scope resolves besides its keys (SEMANTICS §24): the
|
|
115
|
+
// group's rows, the key record itself, and its identity.
|
|
116
|
+
const GROUP_SCOPE_NAMES: ReadonlySet<string> = new Set(["rows", "$it", "$key"]);
|
|
111
117
|
const RELOPS = new Set<string>(["==", "!=", "<", "<=", ">", ">="]);
|
|
112
|
-
|
|
118
|
+
// The comparison level (GRAMMAR §4): the six relational operators and, since
|
|
119
|
+
// 0.22, the four regex operators — sugar over `matches`, non-associative like
|
|
120
|
+
// the rest of the level.
|
|
121
|
+
const CMP_OPS = new Set<string>(["==", "!=", "<", "<=", ">", ">=", "~", "~*", "!~", "!~*"]);
|
|
113
122
|
const ADD_OPS = new Set<string>(["+", "-"]);
|
|
114
123
|
const MUL_OPS = new Set<string>(["*", "/", "%"]);
|
|
115
124
|
// `true`/`false`/`null` are literals in every position, so they can never name a
|
|
@@ -149,6 +158,8 @@ interface BodyClauses {
|
|
|
149
158
|
follow: Follow | null;
|
|
150
159
|
frontier: Expr | null;
|
|
151
160
|
depth: number | null;
|
|
161
|
+
groupBy: GroupBy | null;
|
|
162
|
+
having: Where | null;
|
|
152
163
|
distinct: boolean;
|
|
153
164
|
values: boolean;
|
|
154
165
|
limit: Expr | null;
|
|
@@ -262,6 +273,8 @@ class Parser {
|
|
|
262
273
|
consumer: "collect",
|
|
263
274
|
follow: body.follow,
|
|
264
275
|
frontier: body.frontier,
|
|
276
|
+
groupBy: body.groupBy,
|
|
277
|
+
having: body.having,
|
|
265
278
|
distinct: body.distinct,
|
|
266
279
|
values: body.values,
|
|
267
280
|
limit: body.limit,
|
|
@@ -274,8 +287,8 @@ class Parser {
|
|
|
274
287
|
private queryFromOp(e: OpNode, start: number): Query {
|
|
275
288
|
return {
|
|
276
289
|
kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
|
|
277
|
-
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier,
|
|
278
|
-
limit: e.sub.limit, offset: e.sub.offset,
|
|
290
|
+
orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, groupBy: e.sub.groupBy, having: e.sub.having,
|
|
291
|
+
distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
|
|
279
292
|
};
|
|
280
293
|
}
|
|
281
294
|
|
|
@@ -297,6 +310,8 @@ class Parser {
|
|
|
297
310
|
let follow: Follow | null = null;
|
|
298
311
|
let frontier: Expr | null = null;
|
|
299
312
|
let depth: number | null = null;
|
|
313
|
+
let groupBy: GroupBy | null = null;
|
|
314
|
+
let having: Where | null = null;
|
|
300
315
|
let distinct = false;
|
|
301
316
|
let values = false;
|
|
302
317
|
let limit: Expr | null = null;
|
|
@@ -362,6 +377,20 @@ class Parser {
|
|
|
362
377
|
follow = this.atDepth(rowDepth, () => this.parseFollow());
|
|
363
378
|
continue;
|
|
364
379
|
}
|
|
380
|
+
// `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
|
|
381
|
+
if (this.atGroupBy()) {
|
|
382
|
+
enter("group by");
|
|
383
|
+
groupBy = this.atDepth(rowDepth, () => this.parseGroupBy());
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// `having P` (0.24): a predicate over each GROUP, read at the row depth
|
|
387
|
+
// (the group scope sits where the rows did).
|
|
388
|
+
if (this.atHaving()) {
|
|
389
|
+
enter("having");
|
|
390
|
+
this.next();
|
|
391
|
+
having = this.atDepth(rowDepth, () => this.parseWhere());
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
365
394
|
if (this.atOrderBy()) {
|
|
366
395
|
enter("order by");
|
|
367
396
|
this.next(); this.next(); // `order` `by`
|
|
@@ -401,14 +430,213 @@ class Parser {
|
|
|
401
430
|
// Validate the alias references of this body (cycles, a block alias inside a
|
|
402
431
|
// `where` expression, a `select` item naming an item to its right); the
|
|
403
432
|
// surface form is kept. `where` first: its cycle report follows its own path.
|
|
404
|
-
|
|
405
|
-
|
|
433
|
+
// A grouped body without a projection yields its keys and a count (0.24);
|
|
434
|
+
// the items are synthesized before the alias validation sees the select.
|
|
435
|
+
if (groupBy && select.length === 0) select = defaultGroupProjection(groupBy, (msg) => this.failAt(groupBy!.span[0], msg));
|
|
436
|
+
const resolvedWhere = where ? resolveWhere(select, where, (msg) => this.fail(msg)) : null;
|
|
437
|
+
const resolvedHaving = having ? resolveWhere(select, having, (msg) => this.fail(msg)) : null;
|
|
438
|
+
// The resolved items: an aggregate reached through an alias (`n: count(),
|
|
439
|
+
// xs: collect(n)`) is judged where it will be evaluated.
|
|
440
|
+
const resolvedSelect = resolveSelect(select, (msg) => this.fail(msg));
|
|
406
441
|
// Outside a destination block `frontier` marks the seeds of this body's walk,
|
|
407
442
|
// so it needs one.
|
|
408
443
|
if (frontier && !follow && !ctx.destination) {
|
|
409
444
|
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
445
|
}
|
|
411
|
-
|
|
446
|
+
// Grouping (0.24): `having` needs a `group by`; `distinct` is redundant with
|
|
447
|
+
// one; aggregates may stand only in the post-group clauses; and every bare
|
|
448
|
+
// name read at the group scope must be a key, an alias, or the group's rows.
|
|
449
|
+
if (having && !groupBy) {
|
|
450
|
+
this.failAt(having.span[0], "`having` needs a `group by` — `having <pred>` filters the groups a body yields; a per-row filter is `where`");
|
|
451
|
+
}
|
|
452
|
+
if (groupBy) {
|
|
453
|
+
if (distinct) this.failAt(groupBy.span[0], DISTINCT_WITH_GROUP);
|
|
454
|
+
this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy });
|
|
455
|
+
} else {
|
|
456
|
+
this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy: null });
|
|
457
|
+
}
|
|
458
|
+
return { froms, where, select, orderBy, follow, frontier, depth, groupBy, having, distinct, values, limit, offset };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---- grouping (0.24) ------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
// `group by` is a clause only as the two-word sequence (a field named `group`
|
|
464
|
+
// stays a name: `select group from r`).
|
|
465
|
+
private atGroupBy(): boolean {
|
|
466
|
+
const t = this.peek();
|
|
467
|
+
const nx = this.peekAt(1);
|
|
468
|
+
return t.type === "ident" && t.value === "group" && !!nx && nx.type === "ident" && nx.value === "by";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// `having` is a clause when a predicate can follow it — a value start, or an
|
|
472
|
+
// aggregate call whose name is a consumer word (`having count() > 1`:
|
|
473
|
+
// `count` alone cannot begin a value, `count(` can); otherwise a field name.
|
|
474
|
+
private atHaving(): boolean {
|
|
475
|
+
if (!this.at("ident", "having")) return false;
|
|
476
|
+
const nx = this.peekAt(1);
|
|
477
|
+
return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private parseGroupBy(): GroupBy {
|
|
481
|
+
const start = this.start();
|
|
482
|
+
this.next(); this.next(); // `group` `by`
|
|
483
|
+
const keys = [this.parseGroupKey()];
|
|
484
|
+
while (this.at("comma")) { this.next(); keys.push(this.parseGroupKey()); }
|
|
485
|
+
const seen = new Set<string>();
|
|
486
|
+
for (const k of keys) {
|
|
487
|
+
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\`)`);
|
|
488
|
+
seen.add(k.name);
|
|
489
|
+
}
|
|
490
|
+
return { kind: "groupBy", span: this.sp(start), keys };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// One key, shaped like a select item: `name: expr`, or an unaliased navigation
|
|
494
|
+
// keyed by its last segment; any other expression needs the alias, because the
|
|
495
|
+
// alias is how the post-group clauses refer to the key.
|
|
496
|
+
private parseGroupKey(): GroupKey {
|
|
497
|
+
const start = this.start();
|
|
498
|
+
if (this.at("ident") && this.peekAt(1)?.type === "colon") {
|
|
499
|
+
const name = this.next().value;
|
|
500
|
+
this.next(); // ':'
|
|
501
|
+
const expr = this.parseValueExpr();
|
|
502
|
+
return { kind: "key", span: this.sp(start), name, expr };
|
|
503
|
+
}
|
|
504
|
+
if (!this.canStartValue()) this.fail("expected a group key after `group by` (`group by <name>` or `group by <alias>: <expr>`)");
|
|
505
|
+
const expr = this.parseValueExpr();
|
|
506
|
+
const name = navKey(expr);
|
|
507
|
+
if (name === null) {
|
|
508
|
+
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`);
|
|
509
|
+
}
|
|
510
|
+
return { kind: "key", span: this.sp(start), name, expr };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// `name(` where `name` is an aggregate: `count()`, `sum(e)`, `collect(distinct
|
|
514
|
+
// e)`. The cursor is at the `(`. Arity and the `distinct` modifier are checked
|
|
515
|
+
// here; WHERE the aggregate may stand is the body's business (`checkGroupedBody`).
|
|
516
|
+
private parseAggregate(name: AggregateName, start: number): Expr {
|
|
517
|
+
this.next(); // '('
|
|
518
|
+
let distinct = false;
|
|
519
|
+
if (this.at("ident", "distinct") && this.startsValue(this.peekAt(1))) { this.next(); distinct = true; }
|
|
520
|
+
let arg: Expr | null = null;
|
|
521
|
+
if (!this.at("rparen")) {
|
|
522
|
+
arg = this.parseValueExpr();
|
|
523
|
+
if (this.at("comma")) this.fail(`\`${name}(…)\` takes one argument — an aggregate reduces one expression over the group's rows`);
|
|
524
|
+
}
|
|
525
|
+
if (!this.at("rparen")) this.fail(`expected ')' to close \`${name}(…)\``);
|
|
526
|
+
this.next();
|
|
527
|
+
if (arg === null && name !== "count") this.fail(`\`${name}()\` needs an argument (\`${name}(<expr>)\`) — only \`count()\` counts the group's rows without one`);
|
|
528
|
+
if (distinct && (name === "any" || name === "all")) {
|
|
529
|
+
this.fail(`\`${name}(distinct …)\`: \`distinct\` applies to a value aggregate (count, sum, min, max, avg, collect, first, last), not to a predicate aggregate`);
|
|
530
|
+
}
|
|
531
|
+
return { kind: "aggregate", span: [start, this.end()], name, arg, distinct };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A free call: an aggregate node for an aggregate name (reserved since 0.24),
|
|
535
|
+
// an ordinary `call` otherwise. The cursor is at the `(`.
|
|
536
|
+
private parseFreeCall(name: string, start: number): Expr {
|
|
537
|
+
if (isAggregateName(name)) return this.parseAggregate(name, start);
|
|
538
|
+
const args = this.parseArgs();
|
|
539
|
+
return { kind: "call", span: this.sp(start), recv: null, name, args };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// The clauses an aggregate may NOT stand in — every clause read before the
|
|
543
|
+
// rows are grouped (`from`, `where`, `frontier`, the keys, the bound) — and,
|
|
544
|
+
// in a body without `group by`, every clause. The `where` is the alias-resolved
|
|
545
|
+
// one, so an aggregate reached through a select alias is caught too.
|
|
546
|
+
private rejectAggregates(b: GroupCheck): void {
|
|
547
|
+
const found: [Expr, string][] = [];
|
|
548
|
+
const collect = (exprs: (Expr | null)[], clause: string): void => {
|
|
549
|
+
const out: Expr[] = [];
|
|
550
|
+
for (const e of exprs) if (e) aggregatesIn(e, out);
|
|
551
|
+
for (const a of out) found.push([a, clause]);
|
|
552
|
+
};
|
|
553
|
+
const where: Expr[] = [];
|
|
554
|
+
if (b.where) whereAggregates(b.where, where);
|
|
555
|
+
for (const a of where) found.push([a, "`where`"]);
|
|
556
|
+
collect(b.froms, "`from`");
|
|
557
|
+
collect([b.frontier], "`frontier`");
|
|
558
|
+
collect([b.limit], "`limit`");
|
|
559
|
+
collect([b.offset], "`offset`");
|
|
560
|
+
if (b.groupBy) collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
|
|
561
|
+
if (!b.groupBy) {
|
|
562
|
+
collect(b.resolvedSelect.filter((it) => it.kind === "field").map((it) => (it as Extract<SelectItem, { kind: "field" }>).expr), "a select item");
|
|
563
|
+
if (b.orderBy) collect(b.orderBy.map((o) => o.expr), "`order by`");
|
|
564
|
+
}
|
|
565
|
+
const hit = found[0];
|
|
566
|
+
if (!hit) return;
|
|
567
|
+
const [agg, clause] = hit;
|
|
568
|
+
if (!b.groupBy) {
|
|
569
|
+
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`);
|
|
570
|
+
}
|
|
571
|
+
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\``);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// A grouped body: aggregates only after the grouping (and never nested), and
|
|
575
|
+
// every bare name at the group scope a key, an alias, or the group's rows.
|
|
576
|
+
private checkGroupedBody(b: GroupCheck): void {
|
|
577
|
+
this.rejectAggregates(b);
|
|
578
|
+
// An aggregate's argument is read on the group's rows, where there is no
|
|
579
|
+
// group to aggregate: `sum(count())` is an error, not a double reduction.
|
|
580
|
+
const post: Expr[] = [];
|
|
581
|
+
for (const it of b.resolvedSelect) if (it.kind === "field") aggregatesIn(it.expr, post);
|
|
582
|
+
if (b.resolvedHaving) whereAggregates(b.resolvedHaving, post);
|
|
583
|
+
if (b.orderBy) for (const o of b.orderBy) aggregatesIn(o.expr, post);
|
|
584
|
+
const nested = nestedAggregate(post);
|
|
585
|
+
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`);
|
|
586
|
+
const keys = new Set(b.groupBy!.keys.map((k) => k.name));
|
|
587
|
+
const valid = (aliases: Iterable<string>): ReadonlySet<string> => new Set([...keys, ...GROUP_SCOPE_NAMES, ...aliases]);
|
|
588
|
+
const aliases: string[] = [];
|
|
589
|
+
for (const it of b.select) {
|
|
590
|
+
const names = valid(aliases);
|
|
591
|
+
if (it.kind === "field") this.checkGroupExpr(it.expr, names, "a select item");
|
|
592
|
+
else this.checkGroupExpr(it.op.receiver, names, "a select item");
|
|
593
|
+
const own = it.kind === "collect" ? (it.name === "" ? null : it.name) : it.lift === 0 && it.name !== "" ? it.name : null;
|
|
594
|
+
if (own !== null) aliases.push(own);
|
|
595
|
+
}
|
|
596
|
+
if (b.having) this.checkGroupWhere(b.having, valid(aliases), "`having`");
|
|
597
|
+
if (b.orderBy) for (const o of b.orderBy) this.checkGroupExpr(o.expr, valid([]), "`order by`");
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private checkGroupWhere(w: Where, valid: ReadonlySet<string>, where: string): void {
|
|
601
|
+
switch (w.kind) {
|
|
602
|
+
case "and": case "or": w.parts.forEach((p) => this.checkGroupWhere(p, valid, where)); return;
|
|
603
|
+
case "not": this.checkGroupWhere(w.expr, valid, where); return;
|
|
604
|
+
case "scalar": this.checkGroupExpr(w.expr, valid, where); return;
|
|
605
|
+
case "op": this.checkGroupExpr(w.receiver, valid, where); return; // the block is its own scope
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Every bare name `e` reads at the group scope (an aggregate's argument reads
|
|
610
|
+
// the group's rows and is not checked; a nested block is its own scope, only
|
|
611
|
+
// its receiver is read here). `inCall` names the enclosing non-aggregate free
|
|
612
|
+
// call, for the "not an aggregate" hint.
|
|
613
|
+
private checkGroupExpr(e: Expr, valid: ReadonlySet<string>, where: string, inCall: string | null = null): void {
|
|
614
|
+
switch (e.kind) {
|
|
615
|
+
case "ident":
|
|
616
|
+
if (!valid.has(e.name)) {
|
|
617
|
+
const hint = inCall === null ? "" : `; \`${inCall}(…)\` is not an aggregate — the aggregates are ${[...AGGREGATE_NAMES].join(", ")}`;
|
|
618
|
+
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}`);
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
case "lit": case "binding": case "outer": case "aggregate": return;
|
|
622
|
+
case "member": this.checkGroupExpr(e.recv, valid, where, inCall); return;
|
|
623
|
+
case "call":
|
|
624
|
+
if (e.recv) this.checkGroupExpr(e.recv, valid, where, inCall);
|
|
625
|
+
for (const a of e.args) this.checkGroupExpr(a, valid, where, e.recv === null ? e.name : inCall);
|
|
626
|
+
return;
|
|
627
|
+
case "unary": case "required": this.checkGroupExpr(e.expr, valid, where, inCall); return;
|
|
628
|
+
case "binary": case "logical": case "in":
|
|
629
|
+
this.checkGroupExpr(e.left, valid, where, inCall);
|
|
630
|
+
this.checkGroupExpr(e.right, valid, where, inCall);
|
|
631
|
+
return;
|
|
632
|
+
case "range":
|
|
633
|
+
if (e.lo) this.checkGroupExpr(e.lo, valid, where, inCall);
|
|
634
|
+
if (e.hi) this.checkGroupExpr(e.hi, valid, where, inCall);
|
|
635
|
+
return;
|
|
636
|
+
case "tuple": e.items.forEach((x) => this.checkGroupExpr(x, valid, where, inCall)); return;
|
|
637
|
+
case "template": e.parts.forEach((p) => { if (typeof p !== "string") this.checkGroupExpr(p, valid, where, inCall); }); return;
|
|
638
|
+
case "op": this.checkGroupExpr(e.receiver, valid, where, inCall); return;
|
|
639
|
+
}
|
|
412
640
|
}
|
|
413
641
|
|
|
414
642
|
// The keyword-less run that opens a body. At the top level it is always the
|
|
@@ -485,6 +713,8 @@ class Parser {
|
|
|
485
713
|
}
|
|
486
714
|
if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
|
|
487
715
|
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\``);
|
|
716
|
+
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>\`)`);
|
|
717
|
+
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\``);
|
|
488
718
|
}
|
|
489
719
|
if (last === "from" && t.type === "ident" && CONSUMERS.has(t.value)) {
|
|
490
720
|
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\``);
|
|
@@ -656,10 +886,7 @@ class Parser {
|
|
|
656
886
|
? { kind: "outer", span: [start, head.end], levels, name: head.value }
|
|
657
887
|
: { kind: "ident", span: [start, head.end], name: head.value };
|
|
658
888
|
let name = head.value;
|
|
659
|
-
if (levels === 0 && this.at("lparen"))
|
|
660
|
-
const args = this.parseArgs();
|
|
661
|
-
expr = { kind: "call", span: this.sp(start), recv: null, name: head.value, args };
|
|
662
|
-
}
|
|
889
|
+
if (levels === 0 && this.at("lparen")) expr = this.parseFreeCall(head.value, start);
|
|
663
890
|
while (this.at("dot")) {
|
|
664
891
|
this.next();
|
|
665
892
|
if (!this.at("ident")) this.fail("expected a property name after '.'");
|
|
@@ -725,8 +952,8 @@ class Parser {
|
|
|
725
952
|
if (op) {
|
|
726
953
|
// `name: jobs first { }.pay`: the directive is the head of a longer value.
|
|
727
954
|
if (this.atScalarContinuation() || this.atValueDirective()) return this.finishItem(name, this.parseOr(op), lift, start);
|
|
728
|
-
if (op.op !== "collect" && op.op !== "first" && op.op !== "single") {
|
|
729
|
-
this.fail(`projection '${name}' must use collect/first/single, not ${op.op} (exists/none
|
|
955
|
+
if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
|
|
956
|
+
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)`);
|
|
730
957
|
}
|
|
731
958
|
return this.finishItem(name, op, lift, start);
|
|
732
959
|
}
|
|
@@ -1007,6 +1234,9 @@ class Parser {
|
|
|
1007
1234
|
this.deferLookup = defer;
|
|
1008
1235
|
if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
|
|
1009
1236
|
this.next();
|
|
1237
|
+
// `x collect distinct { group by k }`: the directive's distinct is as
|
|
1238
|
+
// redundant as the body's own (checked in parseBody).
|
|
1239
|
+
if (distinct && body.groupBy) this.failAt(body.groupBy.span[0], DISTINCT_WITH_GROUP);
|
|
1010
1240
|
const sub: Subquery = {
|
|
1011
1241
|
kind: "subquery",
|
|
1012
1242
|
span: this.sp(lbrace),
|
|
@@ -1017,6 +1247,8 @@ class Parser {
|
|
|
1017
1247
|
follow: body.follow,
|
|
1018
1248
|
frontier: body.frontier,
|
|
1019
1249
|
depth: body.depth,
|
|
1250
|
+
groupBy: body.groupBy,
|
|
1251
|
+
having: body.having,
|
|
1020
1252
|
values: body.values,
|
|
1021
1253
|
limit: body.limit,
|
|
1022
1254
|
offset: body.offset,
|
|
@@ -1075,7 +1307,7 @@ class Parser {
|
|
|
1075
1307
|
this.next();
|
|
1076
1308
|
const subSpan: Span = [lbracket, this.end()];
|
|
1077
1309
|
const sub = (parts: Partial<Subquery>): Subquery => ({
|
|
1078
|
-
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null, ...parts,
|
|
1310
|
+
kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, groupBy: null, having: null, values: false, limit: null, offset: null, ...parts,
|
|
1079
1311
|
});
|
|
1080
1312
|
const bang = this.atOp("!") ? this.next() : null;
|
|
1081
1313
|
if (index) {
|
|
@@ -1234,9 +1466,9 @@ class Parser {
|
|
|
1234
1466
|
expr = { kind: "member", span: this.sp(start), recv: expr, name };
|
|
1235
1467
|
}
|
|
1236
1468
|
} else if (this.at("lparen") && left === undefined && expr.kind === "ident") {
|
|
1237
|
-
// free function call: name(args) — a bare name read here, not `(f)(x)
|
|
1238
|
-
|
|
1239
|
-
expr =
|
|
1469
|
+
// free function call: name(args) — a bare name read here, not `(f)(x)`;
|
|
1470
|
+
// an aggregate name makes it an `aggregate` node (0.24)
|
|
1471
|
+
expr = this.parseFreeCall(expr.name, start);
|
|
1240
1472
|
} else if (this.at("lbracket")) {
|
|
1241
1473
|
expr = this.parseBracket(expr);
|
|
1242
1474
|
} else if (this.atOp("!")) {
|
|
@@ -1259,9 +1491,12 @@ class Parser {
|
|
|
1259
1491
|
return expr;
|
|
1260
1492
|
}
|
|
1261
1493
|
|
|
1494
|
+
// The directives that have a value form: collect/first/single, and since 0.24
|
|
1495
|
+
// `count { … }` (the number of rows the block yields). exists/none are
|
|
1496
|
+
// predicates only.
|
|
1262
1497
|
private atValueDirective(): boolean {
|
|
1263
1498
|
const op = this.atConsumerBlock();
|
|
1264
|
-
return op === "collect" || op === "first" || op === "single";
|
|
1499
|
+
return op === "collect" || op === "first" || op === "single" || op === "count";
|
|
1265
1500
|
}
|
|
1266
1501
|
|
|
1267
1502
|
private parseArgs(): Expr[] {
|
|
@@ -1352,6 +1587,78 @@ class Parser {
|
|
|
1352
1587
|
}
|
|
1353
1588
|
}
|
|
1354
1589
|
|
|
1590
|
+
// ---- grouping helpers (0.24) --------------------------------------------------
|
|
1591
|
+
|
|
1592
|
+
const DISTINCT_WITH_GROUP = "`distinct` is redundant with `group by` — a grouped body already yields one row per distinct key tuple; drop `distinct`";
|
|
1593
|
+
|
|
1594
|
+
// The clauses `checkGroupedBody` / `rejectAggregates` look at: the body's own
|
|
1595
|
+
// expressions, with `where` (and `resolvedHaving`) already alias-resolved.
|
|
1596
|
+
interface GroupCheck {
|
|
1597
|
+
select: SelectItem[];
|
|
1598
|
+
/** The items with their aliases substituted (`resolveSelect`), for the aggregate checks. */
|
|
1599
|
+
resolvedSelect: SelectItem[];
|
|
1600
|
+
having: Where | null;
|
|
1601
|
+
resolvedHaving: Where | null;
|
|
1602
|
+
orderBy: OrderSpec[] | null;
|
|
1603
|
+
froms: Expr[];
|
|
1604
|
+
where: Where | null;
|
|
1605
|
+
frontier: Expr | null;
|
|
1606
|
+
limit: Expr | null;
|
|
1607
|
+
offset: Expr | null;
|
|
1608
|
+
groupBy: GroupBy | null;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// The projection of a grouped body that writes none (SEMANTICS §24): every key
|
|
1612
|
+
// by its name, then `count: count()`. Synthesized here so the tree shows what
|
|
1613
|
+
// the body projects and the engine has no grouped "row itself" to define; the
|
|
1614
|
+
// nodes carry the empty span (they did not come from source).
|
|
1615
|
+
function defaultGroupProjection(groupBy: GroupBy, fail: (msg: string) => never): SelectItem[] {
|
|
1616
|
+
if (groupBy.keys.some((k) => k.name === "count")) {
|
|
1617
|
+
fail("a group key named `count` needs an explicit `select` — the default projection of a grouped body is its keys plus `count: count()`");
|
|
1618
|
+
}
|
|
1619
|
+
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 }));
|
|
1620
|
+
items.push({ kind: "field", span: [0, 0], name: "count", expr: { kind: "aggregate", span: [0, 0], name: "count", arg: null, distinct: false }, lift: 0 });
|
|
1621
|
+
return items;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Every aggregate node in `e` read at THIS scope (a nested block's body is its
|
|
1625
|
+
// own scope and is not entered; a block's receiver is). An aggregate's argument
|
|
1626
|
+
// is entered too, so a nested aggregate is found by `nestedAggregate`.
|
|
1627
|
+
function aggregatesIn(e: Expr, out: Expr[]): void {
|
|
1628
|
+
switch (e.kind) {
|
|
1629
|
+
case "aggregate": out.push(e); if (e.arg) aggregatesIn(e.arg, out); return;
|
|
1630
|
+
case "lit": case "ident": case "outer": case "binding": return;
|
|
1631
|
+
case "member": aggregatesIn(e.recv, out); return;
|
|
1632
|
+
case "call": if (e.recv) aggregatesIn(e.recv, out); e.args.forEach((a) => aggregatesIn(a, out)); return;
|
|
1633
|
+
case "unary": case "required": aggregatesIn(e.expr, out); return;
|
|
1634
|
+
case "binary": case "logical": case "in": aggregatesIn(e.left, out); aggregatesIn(e.right, out); return;
|
|
1635
|
+
case "range": if (e.lo) aggregatesIn(e.lo, out); if (e.hi) aggregatesIn(e.hi, out); return;
|
|
1636
|
+
case "tuple": e.items.forEach((x) => aggregatesIn(x, out)); return;
|
|
1637
|
+
case "template": e.parts.forEach((p) => { if (typeof p !== "string") aggregatesIn(p, out); }); return;
|
|
1638
|
+
case "op": aggregatesIn(e.receiver, out); return;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function whereAggregates(w: Where, out: Expr[]): void {
|
|
1643
|
+
switch (w.kind) {
|
|
1644
|
+
case "and": case "or": w.parts.forEach((p) => whereAggregates(p, out)); return;
|
|
1645
|
+
case "not": whereAggregates(w.expr, out); return;
|
|
1646
|
+
case "scalar": aggregatesIn(w.expr, out); return;
|
|
1647
|
+
case "op": aggregatesIn(w.receiver, out); return;
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// The first aggregate whose argument holds another (`sum(count())`), if any.
|
|
1652
|
+
function nestedAggregate(found: readonly Expr[]): Expr | null {
|
|
1653
|
+
for (const a of found) {
|
|
1654
|
+
if (a.kind !== "aggregate" || !a.arg) continue;
|
|
1655
|
+
const inner: Expr[] = [];
|
|
1656
|
+
aggregatesIn(a.arg, inner);
|
|
1657
|
+
if (inner.length > 0) return a;
|
|
1658
|
+
}
|
|
1659
|
+
return null;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1355
1662
|
// The default key of an unaliased projection item: the last segment of a bare /
|
|
1356
1663
|
// dotted / outer navigation (`name`, `meta.slug` → "slug", `^name`), else null.
|
|
1357
1664
|
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, 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,8 +67,9 @@ 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
|
+
// Comparison, identity and the regex operators (0.22) share the cmp level.
|
|
72
73
|
case "binary": return e.op === "+" || e.op === "-" ? ADD : e.op === "*" || e.op === "/" || e.op === "%" ? MUL : CMP;
|
|
73
74
|
case "in": return CMP;
|
|
74
75
|
case "range": return RANGE;
|
|
@@ -82,6 +83,8 @@ function render(n: AstNode): Out {
|
|
|
82
83
|
case "subquery": return block(n);
|
|
83
84
|
case "op": return op(n);
|
|
84
85
|
case "follow": return follow(n);
|
|
86
|
+
case "groupBy": return groupBy(n);
|
|
87
|
+
case "key": return key(n);
|
|
85
88
|
case "order": return order(n);
|
|
86
89
|
case "field": case "collect": return item(n);
|
|
87
90
|
case "and": case "or": case "not": case "scalar": return where(n);
|
|
@@ -114,7 +117,7 @@ function query(q: Query): Out {
|
|
|
114
117
|
return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "orderBy" | "limit" | "offset">
|
|
120
|
+
type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
|
|
118
121
|
& Partial<Pick<Subquery, "frontier" | "depth">>;
|
|
119
122
|
|
|
120
123
|
// The clauses after `from` (a query) or after the projection (a block): the
|
|
@@ -127,6 +130,8 @@ function tail(b: Omit<Body, "select" | "values">): Out[] {
|
|
|
127
130
|
if (b.frontier) out.push(cat("frontier ", expr(b.frontier)));
|
|
128
131
|
if (b.depth != null) out.push([`depth ${number(b.depth)}`]);
|
|
129
132
|
if (b.follow) out.push(follow(b.follow));
|
|
133
|
+
if (b.groupBy) out.push(groupBy(b.groupBy));
|
|
134
|
+
if (b.having) out.push(cat("having ", where(b.having)));
|
|
130
135
|
if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
|
|
131
136
|
if (b.limit) out.push(cat("limit ", expr(b.limit)));
|
|
132
137
|
if (b.offset) out.push(cat("offset ", expr(b.offset)));
|
|
@@ -178,6 +183,15 @@ function follow(f: Follow): Out {
|
|
|
178
183
|
return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
// Every key aliased, as every select item is (`layer: layer`).
|
|
187
|
+
function groupBy(g: GroupBy): Out {
|
|
188
|
+
return cat("group by ", join(g.keys.map(key), ", "));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function key(k: GroupKey): Out {
|
|
192
|
+
return cat(`${k.name}: `, expr(k.expr));
|
|
193
|
+
}
|
|
194
|
+
|
|
181
195
|
// ---- where ------------------------------------------------------------------
|
|
182
196
|
|
|
183
197
|
function where(w: Where): Out {
|
|
@@ -211,6 +225,8 @@ function expr(e: Expr): Out {
|
|
|
211
225
|
const args = cat("(", join(e.args.map(expr), ", "), ")");
|
|
212
226
|
return e.recv === null ? cat(e.name, args) : cat(receiver(e.recv), `.${e.name}`, args);
|
|
213
227
|
}
|
|
228
|
+
// An aggregate prints as the call it is written as (`count()`, `sum(distinct x)`).
|
|
229
|
+
case "aggregate": return cat(e.name, "(", e.distinct ? "distinct " : "", e.arg ? expr(e.arg) : "", ")");
|
|
214
230
|
case "unary": return cat(e.op, operand(e.expr, UNARY));
|
|
215
231
|
// Postfix, tightest: anything below postfix level is grouped (`(a + 1)!`).
|
|
216
232
|
case "required": return cat(operand(e.expr, POSTFIX), "!");
|