@omgbase/oqx 0.24.0 → 0.26.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +267 -202
  3. package/dist/ast.d.ts +19 -1
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js.map +1 -1
  6. package/dist/build.d.ts +3 -1
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +4 -2
  9. package/dist/build.js.map +1 -1
  10. package/dist/engine.d.ts +2 -0
  11. package/dist/engine.d.ts.map +1 -1
  12. package/dist/engine.js +95 -44
  13. package/dist/engine.js.map +1 -1
  14. package/dist/optimize/analysis.d.ts +3 -2
  15. package/dist/optimize/analysis.d.ts.map +1 -1
  16. package/dist/optimize/analysis.js +12 -2
  17. package/dist/optimize/analysis.js.map +1 -1
  18. package/dist/optimize/ir.d.ts +4 -1
  19. package/dist/optimize/ir.d.ts.map +1 -1
  20. package/dist/optimize/ir.js +1 -0
  21. package/dist/optimize/ir.js.map +1 -1
  22. package/dist/optimize/rules.d.ts.map +1 -1
  23. package/dist/optimize/rules.js +3 -2
  24. package/dist/optimize/rules.js.map +1 -1
  25. package/dist/parser.d.ts.map +1 -1
  26. package/dist/parser.js +206 -42
  27. package/dist/parser.js.map +1 -1
  28. package/dist/print.d.ts +2 -2
  29. package/dist/print.d.ts.map +1 -1
  30. package/dist/print.js +88 -25
  31. package/dist/print.js.map +1 -1
  32. package/dist/references.d.ts.map +1 -1
  33. package/dist/references.js +39 -1
  34. package/dist/references.js.map +1 -1
  35. package/dist/resolve.d.ts.map +1 -1
  36. package/dist/resolve.js +9 -2
  37. package/dist/resolve.js.map +1 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/dist/walk.d.ts +1 -1
  41. package/dist/walk.d.ts.map +1 -1
  42. package/dist/walk.js +5 -0
  43. package/dist/walk.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/ast.ts +20 -1
  46. package/src/build.ts +5 -3
  47. package/src/engine.ts +89 -39
  48. package/src/optimize/analysis.ts +9 -2
  49. package/src/optimize/ir.ts +5 -1
  50. package/src/optimize/rules.ts +3 -2
  51. package/src/parser.ts +193 -41
  52. package/src/print.ts +92 -30
  53. package/src/references.ts +34 -2
  54. package/src/resolve.ts +10 -3
  55. package/src/version.ts +1 -1
  56. package/src/walk.ts +6 -0
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
@@ -24,17 +24,27 @@
24
24
  // that destination's block, whose body admits two clauses no other block has —
25
25
  // `frontier P` and `depth n`, in the clause order after `where`.
26
26
  //
27
- // Every clause is optional except that a top-level body needs `from` (the
28
- // receiver-plus-consumer form `<receiver> <consumer> { block }` supplies the
29
- // source itself, so its block's `from` is an optional re-projection). Only
27
+ // A query names its source BEFORE its body (0.26, the collection-scoped form):
28
+ // `<collection> [<consumer>] [distinct] { body }` — the receiver supplies the
29
+ // rows, so the body never names its source and its `from` is the re-projection
30
+ // (a flatMap). The clause form `select … from S …` is still read as the same
31
+ // tree (its top-level body needs `from`); the printer never writes it. Only
30
32
  // `select` may drop its keyword, and only when it is the first clause written
31
- // (`name, age from people`); every other clause always carries its keyword. A
32
- // BLOCK may instead lead with a predicate (since 0.17): a leading expression that
33
- // is syntactically a predicate — not a bare name, a dotted navigation or a lift —
34
- // opens a `where`-first body (`people exists { age > 50 }`); at the top level a
35
- // leading predicate is still an error, since `where` would precede `from`. An
33
+ // (`people { name, age }`); every other clause always carries its keyword. A
34
+ // body with a receiver may instead lead with a predicate (since 0.17): a
35
+ // leading expression that is syntactically a predicate — not a bare name, a
36
+ // dotted navigation or a lift — opens a `where`-first body (`people { age > 50
37
+ // }`, `people exists { age > 50 }`); at the clause form's top level a leading
38
+ // predicate is still an error, since `where` would precede `from`. An
36
39
  // out-of-order clause is a parse error naming the order.
37
40
  //
41
+ // Two 0.26 shapes desugar HERE as well: a consumer word with no block is the
42
+ // directive over the empty block (`jobs first` ≡ `jobs first { }`, `jobs count
43
+ // > 1`, `_nodes exists`; a consumer word right after a complete receiver can be
44
+ // nothing else), and a projection-only block over a `collect`/`first`/`single`
45
+ // directive whose block projects nothing is that directive's projection
46
+ // (`jobs[0] { name }` ≡ `jobs first { name offset 0 }`, `mergeProjectionBlock`).
47
+ //
38
48
  // Sugar (0.17) desugars HERE, to existing nodes: `x { … }` is `x collect { … }`
39
49
  // wherever a value stands (a select item, a where leaf, a follow destination, a
40
50
  // call argument — since 0.20 without exception) and `x distinct { … }` is
@@ -83,10 +93,11 @@ import { lexTemplate, lexString } from "./lexer.ts";
83
93
  import { OqxError } from "./errors.ts";
84
94
  import type {
85
95
  Query, Where, Expr, OpNode, Subquery, SelectItem, OrderSpec, Follow, FollowDestination, Consumer, RelOp,
86
- BinaryOp, Span, TemplatePart, GroupBy, GroupKey, AggregateName,
96
+ BinaryOp, Span, TemplatePart, Flatten, GroupBy, GroupKey, AggregateName,
87
97
  } from "./ast.ts";
88
98
  import { AGGREGATE_NAMES, isAggregateName } from "./ast.ts";
89
99
  import { resolveSelect, resolveWhere } from "./resolve.ts";
100
+ import { visit } from "./walk.ts";
90
101
  import { printTemplate } from "./print.ts";
91
102
 
92
103
  const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first", "single"]);
@@ -96,8 +107,11 @@ const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first"
96
107
  // `depth` is legal only in a follow destination block, and the order sentence
97
108
  // names it only there. `group by` and `having` (0.24) sit after `follow`: the
98
109
  // 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;
110
+ // grouped, and the bound and the ordering then apply to the groups. `flatten`
111
+ // (0.25) sits between them: it fans the rows a body yields — the occurrences,
112
+ // after a walk — out per element, and is the one clause that may repeat (each
113
+ // `flatten` fans out the rows the previous produced, SEMANTICS §25).
114
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "flatten", "group by", "having", "order by", "limit", "offset"] as const;
101
115
  type Clause = (typeof CLAUSE_ORDER)[number];
102
116
  const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
103
117
  // The clauses a body may hold, in order.
@@ -109,7 +123,7 @@ function clausesOf(ctx: BodyCtx): Clause[] {
109
123
  const CLAUSE_WORDS = new Set<string>([
110
124
  "collect", "exists", "none", "count", "first", "single",
111
125
  "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
112
- "limit", "offset", "group", "having",
126
+ "limit", "offset", "group", "having", "flatten", "as",
113
127
  ]);
114
128
  // The names a group scope resolves besides its keys (SEMANTICS §24): the
115
129
  // group's rows, the key record itself, and its identity.
@@ -158,6 +172,7 @@ interface BodyClauses {
158
172
  follow: Follow | null;
159
173
  frontier: Expr | null;
160
174
  depth: number | null;
175
+ flatten: Flatten[];
161
176
  groupBy: GroupBy | null;
162
177
  having: Where | null;
163
178
  distinct: boolean;
@@ -166,6 +181,30 @@ interface BodyClauses {
166
181
  offset: Expr | null;
167
182
  }
168
183
 
184
+ /** The clauses of an omitted block (`jobs first` — the empty block `{ }`). */
185
+ function emptyBody(): BodyClauses {
186
+ return { froms: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, flatten: [], groupBy: null, having: null, distinct: false, values: false, limit: null, offset: null };
187
+ }
188
+
189
+ /** The names a projection would shadow in a `where` of the same body: every
190
+ * item's name except a plain self-projection (`pay: pay`, `pay`), which
191
+ * substitutes a name for itself. */
192
+ function shadowingNames(items: SelectItem[]): string[] {
193
+ return items
194
+ .filter((it) => it.name !== "" && !(it.kind === "field" && it.lift === 0 && it.expr.kind === "ident" && it.expr.name === it.name))
195
+ .map((it) => it.name);
196
+ }
197
+
198
+ /** Whether a bare identifier anywhere under `w` has one of `names` (the alias
199
+ * guard of the projection-block merge; conservative: nested blocks included). */
200
+ function readsAnyOf(w: Where, names: string[]): boolean {
201
+ const wanted = new Set(names.filter((n) => n !== ""));
202
+ if (wanted.size === 0) return false;
203
+ let found = false;
204
+ visit(w, { enter(node) { if (node.kind === "ident" && wanted.has(node.name)) found = true; return !found; } });
205
+ return found;
206
+ }
207
+
169
208
  // A copy of a node with another span (a parenthesized operand takes the span of
170
209
  // its parentheses).
171
210
  function withSpan<T extends { span: Span }>(node: T, span: Span): T {
@@ -184,6 +223,11 @@ class Parser {
184
223
  * top-level row's clauses, one deeper per block body and per follow `where`.
185
224
  * An absolute reference `N^name` reads `depth − N` scopes out. */
186
225
  private depth = 0;
226
+ /** Whether a consumer word with no block is read as a directive over the
227
+ * empty block (`jobs first` ≡ `jobs first { }`, since 0.26). Off only while
228
+ * the clause form's top-level `from` expression is read, so `from people
229
+ * count` keeps its whole-query hint (a count is never a source). */
230
+ private bareDirectives = true;
187
231
 
188
232
  constructor(tokens: Token[]) {
189
233
  this.tokens = tokens;
@@ -226,9 +270,10 @@ class Parser {
226
270
  const start = this.start();
227
271
  // Directive form: `<receiver> <consumer> { … }` consuming the whole query.
228
272
  let directive = this.tryOp(false);
229
- if (directive && !this.at("eof") && (this.at("lbracket") || this.atValueDirective())) {
273
+ if (directive && !this.at("eof") && (this.at("lbracket") || this.atBlock() !== null)) {
230
274
  // A longer chain whose outermost node is still a directive
231
- // (`people first { } first { }` — how `people[p][0]` prints back).
275
+ // (`people first { } first { }` — how `people[p][0]` prints back; a block
276
+ // over the directive, `people[0] { name }`, merges into it, 0.26).
232
277
  const save = this.pos;
233
278
  try {
234
279
  const e = this.parsePostfix(directive);
@@ -260,7 +305,7 @@ class Parser {
260
305
  const body = this.parseBody(TOP_CTX);
261
306
  if (!this.at("eof")) this.fail(`unexpected ${this.tokDesc()} after the query — nothing may follow the last clause`);
262
307
  if (body.froms.length === 0) {
263
- this.fail("a query must name its source with `from <collection>` (or be `<collection> <consumer> { … }`)");
308
+ this.fail("a query must name its source before its body: `<collection> { … }` (or `<collection> <consumer> { … }`; the clause form `select … from <collection> …` is still read)");
264
309
  }
265
310
  return {
266
311
  kind: "query",
@@ -273,6 +318,7 @@ class Parser {
273
318
  consumer: "collect",
274
319
  follow: body.follow,
275
320
  frontier: body.frontier,
321
+ flatten: body.flatten,
276
322
  groupBy: body.groupBy,
277
323
  having: body.having,
278
324
  distinct: body.distinct,
@@ -287,7 +333,7 @@ class Parser {
287
333
  private queryFromOp(e: OpNode, start: number): Query {
288
334
  return {
289
335
  kind: "query", span: this.sp(start), source: e.receiver, from: e.sub.from, where: e.sub.where, select: e.sub.select,
290
- orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, groupBy: e.sub.groupBy, having: e.sub.having,
336
+ 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,
291
337
  distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
292
338
  };
293
339
  }
@@ -310,6 +356,7 @@ class Parser {
310
356
  let follow: Follow | null = null;
311
357
  let frontier: Expr | null = null;
312
358
  let depth: number | null = null;
359
+ const flatten: Flatten[] = [];
313
360
  let groupBy: GroupBy | null = null;
314
361
  let having: Where | null = null;
315
362
  let distinct = false;
@@ -324,7 +371,9 @@ class Parser {
324
371
 
325
372
  const enter = (clause: Clause): void => {
326
373
  const idx = CLAUSE_ORDER.indexOf(clause);
327
- if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
374
+ // `flatten` is the one clause that may repeat (0.25): each fans out the
375
+ // rows the previous produced.
376
+ if (idx === stage && clause !== "flatten") this.fail(`duplicate \`${clause}\` clause`);
328
377
  if (idx < stage) {
329
378
  this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
330
379
  }
@@ -342,7 +391,12 @@ class Parser {
342
391
  if (this.at("kw", "from")) {
343
392
  enter("from");
344
393
  this.next();
345
- froms.push(this.parseValueExpr());
394
+ // At the clause form's top level a consumer word after the source is the
395
+ // whole-query mistake (`from people count`): kept as the hint below,
396
+ // not read as a braceless directive (a count is never a source).
397
+ const bare = this.bareDirectives;
398
+ if (ctx.top) this.bareDirectives = false;
399
+ try { froms.push(this.parseValueExpr()); } finally { this.bareDirectives = bare; }
346
400
  continue;
347
401
  }
348
402
  if (this.at("kw", "where")) {
@@ -377,6 +431,14 @@ class Parser {
377
431
  follow = this.atDepth(rowDepth, () => this.parseFollow());
378
432
  continue;
379
433
  }
434
+ // `flatten <expr> [{ … }] [as <name>]` (0.25): the value is read on each
435
+ // row, like `where`; a brace after a receiver is the receiver-block sugar
436
+ // (the `collect` op), so the block needs no slot of its own.
437
+ if (this.atFlatten()) {
438
+ enter("flatten");
439
+ flatten.push(this.atDepth(rowDepth, () => this.parseFlatten(flatten)));
440
+ continue;
441
+ }
380
442
  // `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
381
443
  if (this.atGroupBy()) {
382
444
  enter("group by");
@@ -451,11 +513,52 @@ class Parser {
451
513
  }
452
514
  if (groupBy) {
453
515
  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 });
516
+ this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy });
455
517
  } else {
456
- this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy: null });
518
+ this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy: null });
457
519
  }
458
- return { froms, where, select, orderBy, follow, frontier, depth, groupBy, having, distinct, values, limit, offset };
520
+ return { froms, where, select, orderBy, follow, frontier, depth, flatten, groupBy, having, distinct, values, limit, offset };
521
+ }
522
+
523
+ // ---- flatten (0.25) -------------------------------------------------------
524
+
525
+ // `flatten` is a clause when a value can follow the word; otherwise it is a
526
+ // field name (`select flatten from r`).
527
+ private atFlatten(): boolean {
528
+ if (!this.at("ident", "flatten")) return false;
529
+ const nx = this.peekAt(1);
530
+ // A value start — or an aggregate call whose name is a consumer word
531
+ // (`flatten collect(x)`), admitted so the aggregate check can name it.
532
+ return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
533
+ }
534
+
535
+ // `flatten <expr> [as <name>]`. The expression is any value — a block after a
536
+ // receiver is read by the postfix grammar as the implicit `collect` op (0.20),
537
+ // which is exactly the block over the coerced collection SEMANTICS §25 wants.
538
+ // The name defaults to a plain navigation's last segment (as a select item's
539
+ // or a group key's does); anything else needs `as`. Names are unique within
540
+ // the body, never a `$`-name (the language's), a keyword or a literal word.
541
+ private parseFlatten(earlier: readonly Flatten[]): Flatten {
542
+ const start = this.start();
543
+ this.next(); // `flatten`
544
+ const expr = this.parseValueExpr();
545
+ let name: string;
546
+ if (this.at("ident", "as")) {
547
+ this.next();
548
+ const t = this.peek();
549
+ if (t.type !== "ident") this.fail("expected a name after `as` (`flatten <expr> as <name>`)");
550
+ if (LITERAL_WORDS.has(t.value)) this.fail(`\`${t.value}\` is a literal and cannot name a flattened value (\`flatten <expr> as <name>\`)`);
551
+ if (t.value.startsWith("$")) this.fail(`a flattened value cannot be bound to a \`$\`-name (\`${t.value}\`) — \`$\` belongs to the language; choose another name`);
552
+ name = this.next().value;
553
+ } else {
554
+ const key = navKey(expr);
555
+ if (key === null) {
556
+ this.failAt(start, `\`flatten ${describe(expr)}\` needs a name (\`flatten ${describe(expr)} as <name>\`) — only a plain navigation names itself by its last segment`);
557
+ }
558
+ name = key;
559
+ }
560
+ if (earlier.some((f) => f.name === name)) this.failAt(start, `duplicate flatten name '${name}' — each flatten binds its own name (\`flatten <expr> as <other>\`)`);
561
+ return { kind: "flatten", span: this.sp(start), expr, name };
459
562
  }
460
563
 
461
564
  // ---- grouping (0.24) ------------------------------------------------------
@@ -555,6 +658,7 @@ class Parser {
555
658
  for (const a of where) found.push([a, "`where`"]);
556
659
  collect(b.froms, "`from`");
557
660
  collect([b.frontier], "`frontier`");
661
+ collect(b.flatten.map((f) => f.expr), "`flatten`");
558
662
  collect([b.limit], "`limit`");
559
663
  collect([b.offset], "`offset`");
560
664
  if (b.groupBy) collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
@@ -713,6 +817,7 @@ class Parser {
713
817
  }
714
818
  if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
715
819
  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\``);
820
+ if (t.value === "flatten") this.fail(`unexpected 'flatten' after \`${last}\` — \`flatten\` takes a value to split into rows (\`flatten <expr> [{ … }] [as <name>]\`)`);
716
821
  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
822
  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\``);
718
823
  }
@@ -921,7 +1026,7 @@ class Parser {
921
1026
  for (const it of items) {
922
1027
  if (it.name === "") {
923
1028
  if (it.kind === "collect") this.fail("a nested block in a projection needs a name (`name: <relation> collect { … }`); as a predicate, a consumer test is written `where <relation> exists { … }`");
924
- if (ctx.top) this.fail("a leading expression is a projection (select): an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `where …` after `from` — at the top level a predicate is never implicit (the clause order puts `from` first)");
1029
+ if (ctx.top) this.fail("a leading expression is a projection (select): an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `<collection> { <predicate> }`, or write `where …` after `from` — at the top level of the clause form a predicate is never implicit (the clause order puts `from` first)");
925
1030
  this.fail("an item that is not a plain name needs an alias (`name: expr`) or `values`; to filter by it write `where …` after the projection (a block may lead with a predicate only when nothing is projected: `{ age > 18 }`)");
926
1031
  }
927
1032
  const key = `${"^".repeat(it.kind === "field" ? it.lift : 0)}${it.name}`;
@@ -950,8 +1055,9 @@ class Parser {
950
1055
  this.next(); // ':'
951
1056
  const op = this.tryOp(false);
952
1057
  if (op) {
953
- // `name: jobs first { }.pay`: the directive is the head of a longer value.
954
- if (this.atScalarContinuation() || this.atValueDirective()) return this.finishItem(name, this.parseOr(op), lift, start);
1058
+ // `name: jobs first { }.pay`: the directive is the head of a longer value
1059
+ // (a block over it, `jobs[0] { name }`, merges into it — 0.26).
1060
+ if (this.atScalarContinuation() || this.atBlock() !== null) return this.finishItem(name, this.parseOr(op), lift, start);
955
1061
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
956
1062
  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)`);
957
1063
  }
@@ -1208,13 +1314,18 @@ class Parser {
1208
1314
  }
1209
1315
 
1210
1316
  // The cursor is at a consumer word that opens a block — `<op> {` or
1211
- // `<op> distinct {`. Consumes nothing.
1317
+ // `<op> distinct {` — or, since 0.26, at the word alone: a consumer word
1318
+ // right after a complete receiver can be nothing else (OQX never places two
1319
+ // operands side by side), and it reads as the directive over the empty block
1320
+ // (`jobs first` ≡ `jobs first { }`, `_nodes exists`, `jobs count > 1`). The
1321
+ // braceless reading is off only inside the clause form's top-level `from`
1322
+ // (`bareDirectives`). Consumes nothing.
1212
1323
  private atConsumerBlock(): Consumer | null {
1213
1324
  if (!this.at("ident") || !CONSUMERS.has(this.peek().value)) return null;
1214
1325
  const after = this.peekAt(1);
1215
1326
  const opThenBrace = after?.type === "lbrace";
1216
1327
  const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
1217
- return opThenBrace || opDistinctBrace ? (this.peek().value as Consumer) : null;
1328
+ return opThenBrace || opDistinctBrace || this.bareDirectives ? (this.peek().value as Consumer) : null;
1218
1329
  }
1219
1330
 
1220
1331
  // `[<op>] [distinct] { <sub> }` over an already-parsed receiver; the cursor is
@@ -1226,20 +1337,32 @@ class Parser {
1226
1337
  if (this.at("ident") && CONSUMERS.has(this.peek().value)) this.next(); // the consumer word (absent for `x { … }`)
1227
1338
  let distinct = false;
1228
1339
  if (this.at("ident", "distinct")) { this.next(); distinct = true; }
1229
- const lbrace = this.next().pos; // '{'
1230
- const defer = this.deferLookup;
1231
- this.deferLookup = false;
1232
- // The receiver was read at the enclosing depth; the block's rows are one deeper.
1233
- const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
1234
- this.deferLookup = defer;
1235
- if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
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);
1340
+ let body: BodyClauses;
1341
+ let subSpan: Span;
1342
+ if (this.at("lbrace")) {
1343
+ const lbrace = this.next().pos; // '{'
1344
+ const defer = this.deferLookup;
1345
+ this.deferLookup = false;
1346
+ // The receiver was read at the enclosing depth; the block's rows are one deeper.
1347
+ body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
1348
+ this.deferLookup = defer;
1349
+ if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
1350
+ this.next();
1351
+ // `x collect distinct { group by k }`: the directive's distinct is as
1352
+ // redundant as the body's own (checked in parseBody).
1353
+ if (distinct && body.groupBy) this.failAt(body.groupBy.span[0], DISTINCT_WITH_GROUP);
1354
+ subSpan = this.sp(lbrace);
1355
+ } else {
1356
+ // A consumer word with no block (0.26) is the directive over the empty
1357
+ // block `{ }` — the word was written (only a brace makes a bare
1358
+ // `collect`), so nothing more is consumed; the block spans nothing, at
1359
+ // the end of the word.
1360
+ body = emptyBody();
1361
+ subSpan = [this.end(), this.end()];
1362
+ }
1240
1363
  const sub: Subquery = {
1241
1364
  kind: "subquery",
1242
- span: this.sp(lbrace),
1365
+ span: subSpan,
1243
1366
  from: body.froms,
1244
1367
  where: body.where,
1245
1368
  select: body.select,
@@ -1247,13 +1370,41 @@ class Parser {
1247
1370
  follow: body.follow,
1248
1371
  frontier: body.frontier,
1249
1372
  depth: body.depth,
1373
+ flatten: body.flatten,
1250
1374
  groupBy: body.groupBy,
1251
1375
  having: body.having,
1252
1376
  values: body.values,
1253
1377
  limit: body.limit,
1254
1378
  offset: body.offset,
1255
1379
  };
1256
- return { kind: "op", span: this.sp(startPos), receiver, op, sub, countCmp: null, distinct: distinct || body.distinct };
1380
+ return this.mergeProjectionBlock(receiver, op, distinct || body.distinct, sub, startPos);
1381
+ }
1382
+
1383
+ // A projection-only block over a `collect`/`first`/`single` directive whose
1384
+ // own block projects nothing IS that directive's projection (0.26):
1385
+ // `jobs[0] { name }` ≡ `jobs first { name offset 0 }`, `jobs[p] { name }` ≡
1386
+ // `jobs first { name where p }`, `jobs[0] { }` ≡ `jobs[0]`. The outer items
1387
+ // are read on the very rows the inner block filtered, at the same depth, so
1388
+ // the merge is exact — except when the outer block carries a clause of its
1389
+ // own (those run AFTER the pick: `jobs[0] { name where pay > 2 }` is the
1390
+ // first job, kept only if it pays), when the inner directive dedups
1391
+ // (`distinct` compares the projection), or when an outer alias would shadow a
1392
+ // bare name the inner `where` reads (`jobs[pay > 2] { pay: employer }`): then
1393
+ // the two directives stay.
1394
+ private mergeProjectionBlock(receiver: Expr, op: Consumer, distinct: boolean, sub: Subquery, startPos: number): OpNode {
1395
+ const plain: OpNode = { kind: "op", span: this.sp(startPos), receiver, op, sub, countCmp: null, distinct };
1396
+ if (receiver.kind !== "op" || op !== "collect") return plain;
1397
+ const inner = receiver;
1398
+ if ((inner.op !== "collect" && inner.op !== "first" && inner.op !== "single") || inner.distinct || inner.countCmp !== null) return plain;
1399
+ if (inner.sub.select.length > 0 || inner.sub.values) return plain;
1400
+ const projectionOnly = sub.from.length === 0 && sub.where === null && sub.orderBy === null && sub.follow === null && sub.frontier === null
1401
+ && sub.depth === null && sub.flatten.length === 0 && sub.groupBy === null && sub.having === null && sub.limit === null && sub.offset === null;
1402
+ if (!projectionOnly) return plain;
1403
+ if (inner.sub.where && readsAnyOf(inner.sub.where, shadowingNames(sub.select))) return plain;
1404
+ return {
1405
+ kind: "op", span: this.sp(startPos), receiver: inner.receiver, op: inner.op, countCmp: null, distinct,
1406
+ sub: { ...inner.sub, select: sub.select, values: sub.values, span: [inner.sub.span[0], sub.span[1]] },
1407
+ };
1257
1408
  }
1258
1409
 
1259
1410
  // A tuple `(e1, e2, …)` at the cursor's `(` (0.20): two or more comma-separated
@@ -1307,7 +1458,7 @@ class Parser {
1307
1458
  this.next();
1308
1459
  const subSpan: Span = [lbracket, this.end()];
1309
1460
  const sub = (parts: Partial<Subquery>): Subquery => ({
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,
1461
+ 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,
1311
1462
  });
1312
1463
  const bang = this.atOp("!") ? this.next() : null;
1313
1464
  if (index) {
@@ -1603,6 +1754,7 @@ interface GroupCheck {
1603
1754
  froms: Expr[];
1604
1755
  where: Where | null;
1605
1756
  frontier: Expr | null;
1757
+ flatten: readonly Flatten[];
1606
1758
  limit: Expr | null;
1607
1759
  offset: Expr | null;
1608
1760
  groupBy: GroupBy | null;
package/src/print.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // The canonical printer (spec/oqx/AST.md §6): the one source text of a tree.
2
2
  // Single spaces, double-quoted strings with GRAMMAR §1 escapes, the minimal
3
- // parentheses the precedence table (GRAMMAR §4) needs, `select` written at the
4
- // top level, the connectives as the words `and` / `or` (since 0.18; `!` stays
5
- // `!`), every clause in its canonical position (a destination block's
6
- // `frontier` and `depth` after its `where`, since 0.20). The law both spec runners
7
- // enforce over every fixture query:
3
+ // parentheses the precedence table (GRAMMAR §4) needs, the collection-scoped
4
+ // form `<source> { … }` for every query whose source can head a block (since
5
+ // 0.26; `collect` is the bare brace, every other consumer its word), the
6
+ // projection leading its body without `select`, the connectives as the words
7
+ // `and` / `or` (since 0.18; `!` stays `!`), every clause in its canonical
8
+ // position (a destination block's `frontier` and `depth` after its `where`,
9
+ // since 0.20). The law both spec runners enforce over every fixture query:
8
10
  //
9
11
  // strip(parse(print(parse(q)))) ≡ strip(parse(q))
10
12
  //
@@ -12,15 +14,15 @@
12
14
  // binding is a value, never source text: `print` throws on one; `printTemplate`
13
15
  // emits template fragments around each binding instead.
14
16
 
15
- import type { AstNode, Expr, Follow, GroupBy, GroupKey, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
17
+ import type { AstNode, Expr, Flatten, Follow, GroupBy, GroupKey, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
16
18
  import { OqxError } from "./errors.ts";
17
19
 
18
20
  /** The template form of a tree: `strings` has `count + 1` fragments; the i-th gap
19
21
  * stands for the binding `indices[i]` of the printed tree, so a caller re-runs it
20
22
  * as `parseTemplate(strings, count)` with the values permuted by `indices`. In a
21
23
  * tree straight from `parseTemplate`, `indices` is `0..count-1` unless the
22
- * canonical clause order moved a binding past another (`${0} collect { x: ${1} }`
23
- * prints as `select x: ${1} from ${0}`). */
24
+ * canonical form moved a binding past another (`select x: ${1} from ${0}` prints
25
+ * as `${0} { x: ${1} }`). */
24
26
  export interface Template {
25
27
  strings: string[];
26
28
  count: number;
@@ -83,6 +85,7 @@ function render(n: AstNode): Out {
83
85
  case "subquery": return block(n);
84
86
  case "op": return op(n);
85
87
  case "follow": return follow(n);
88
+ case "flatten": return flatten(n);
86
89
  case "groupBy": return groupBy(n);
87
90
  case "key": return key(n);
88
91
  case "order": return order(n);
@@ -95,29 +98,65 @@ function render(n: AstNode): Out {
95
98
  // ---- query / bodies ---------------------------------------------------------
96
99
 
97
100
  function query(q: Query): Out {
98
- // Body form for `collect` — unless the query carries body-level `from`
99
- // re-projections, which only a block can hold (a second top-level `from` is a
100
- // duplicate clause): then the directive form `source collect { … from E }`.
101
- if (q.consumer === "collect" && q.from.length === 0) {
102
- // `select` is always written — except for a leading bare item literally
103
- // named `distinct`, which the keyword would swallow as the modifier.
104
- const first = q.select[0];
105
- const bareDistinct = !q.distinct && first?.kind === "field" && first.name === "distinct"
106
- && first.expr.kind === "ident" && first.expr.name === "distinct";
107
- const clauses: Out[] = [];
108
- if (q.select.length > 0) {
109
- const items = projection(q.select, q.values);
110
- clauses.push(bareDistinct ? items : cat(`select ${q.distinct ? "distinct " : ""}`, items));
111
- }
112
- clauses.push(cat("from ", expr(q.source)));
113
- clauses.push(...tail(q));
114
- return join(clauses, " ");
101
+ // The collection-scoped form (since 0.26): `<source> [<consumer>] [distinct]
102
+ // { body }` — the source heads the body, `collect` is the bare brace (the
103
+ // directive word is written after an `op` source, where a bare brace is not
104
+ // read as a block), `distinct` stays on the head.
105
+ if (canHeadBlock(q.source)) {
106
+ return cat(expr(q.source), directive(q.consumer, q.distinct, q.source, q));
107
+ }
108
+ // A source that cannot head a block — a literal, a template, a range,
109
+ // arithmetic, a logical or `in` operand, a unary, an aggregate: every one an
110
+ // eval error or a degenerate query — keeps the clause form `select … from S …`
111
+ // (a `collect`; another consumer over such a source is unparseable either way).
112
+ // `select` is always written — except for a leading bare item literally named
113
+ // `distinct`, which the keyword would swallow as the modifier.
114
+ const first = q.select[0];
115
+ const bareDistinct = !q.distinct && first?.kind === "field" && first.name === "distinct"
116
+ && first.expr.kind === "ident" && first.expr.name === "distinct";
117
+ const clauses: Out[] = [];
118
+ if (q.select.length > 0) {
119
+ const items = projection(q.select, q.values);
120
+ clauses.push(bareDistinct ? items : cat(`select ${q.distinct ? "distinct " : ""}`, items));
115
121
  }
116
- // Directive form: `<source> <consumer> [distinct] { body }`.
117
- return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
122
+ clauses.push(cat("from ", expr(q.source)));
123
+ clauses.push(...tail(q));
124
+ return join(clauses, " ");
125
+ }
126
+
127
+ // Whether `e { … }` parses with `e` as the receiver (GRAMMAR §5 and the postfix
128
+ // chain of §4): a tuple, or a chain — navigation, calls, `!`, directives — whose
129
+ // head is a bare name, an outer reference, a binding or a free call. A literal
130
+ // or template head is "not a collection"; an operator (`-x`, `a + b`, `a..b`,
131
+ // `a in b`, `a or b`) or an aggregate cannot head a receiver.
132
+ function canHeadBlock(e: Expr): boolean {
133
+ switch (e.kind) {
134
+ case "ident": case "outer": case "binding": case "tuple": return true;
135
+ case "call": return e.recv === null || chainHead(e.recv);
136
+ case "member": return chainHead(e.recv);
137
+ case "required": return chainHead(e.expr);
138
+ case "op": return chainHead(e.receiver);
139
+ default: return false;
140
+ }
141
+ }
142
+
143
+ // The head of a postfix chain that may take a block: as `canHeadBlock`, but a
144
+ // tuple is a receiver only on its own (`(a, b).x { … }` is not read as one).
145
+ function chainHead(e: Expr): boolean {
146
+ return e.kind !== "tuple" && canHeadBlock(e);
118
147
  }
119
148
 
120
- type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
149
+ // The text between a receiver and its block: ` { ` for the bare-brace `collect`
150
+ // (since 0.26 the canonical spelling), ` distinct { ` for a distinct collect,
151
+ // ` <op> [distinct] { ` for the other consumers — and for a `collect` over an
152
+ // `op` receiver, where a bare brace right after a written directive is not
153
+ // read as a block (`jobs first { offset 0 } collect { … }`).
154
+ function directiveWord(consumer: OpNode["op"], distinct: boolean, receiver: Expr): string {
155
+ if (consumer === "collect" && receiver.kind !== "op") return distinct ? " distinct " : " ";
156
+ return ` ${consumer}${distinct ? " distinct" : ""} `;
157
+ }
158
+
159
+ type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "flatten" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
121
160
  & Partial<Pick<Subquery, "frontier" | "depth">>;
122
161
 
123
162
  // The clauses after `from` (a query) or after the projection (a block): the
@@ -130,6 +169,7 @@ function tail(b: Omit<Body, "select" | "values">): Out[] {
130
169
  if (b.frontier) out.push(cat("frontier ", expr(b.frontier)));
131
170
  if (b.depth != null) out.push([`depth ${number(b.depth)}`]);
132
171
  if (b.follow) out.push(follow(b.follow));
172
+ for (const f of b.flatten) out.push(flatten(f));
133
173
  if (b.groupBy) out.push(groupBy(b.groupBy));
134
174
  if (b.having) out.push(cat("having ", where(b.having)));
135
175
  if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
@@ -172,17 +212,39 @@ function order(o: OrderSpec): Out {
172
212
  function op(o: OpNode): Out {
173
213
  return cat(
174
214
  receiver(o.receiver),
175
- ` ${o.op}${o.distinct ? " distinct" : ""} `,
176
- block(o.sub),
215
+ directive(o.op, o.distinct, o.receiver, o.sub),
177
216
  o.countCmp ? ` ${o.countCmp.op} ${number(o.countCmp.value)}` : "",
178
217
  );
179
218
  }
180
219
 
220
+ // A directive's word and block after its receiver: the bare brace for
221
+ // `collect`, the word for every other consumer — and, when the word is written
222
+ // and the block is empty, the word alone (`jobs first`, `_nodes exists`, `jobs
223
+ // count distinct`; since 0.26 an omitted block is the empty block).
224
+ function directive(consumer: OpNode["op"], distinct: boolean, receiver: Expr, b: Body): Out {
225
+ const word = directiveWord(consumer, distinct, receiver);
226
+ if (word !== " " && word !== " distinct " && emptyBody(b)) return [word.trimEnd()];
227
+ return cat(word, braces(body(b)));
228
+ }
229
+
230
+ function emptyBody(b: Body): boolean {
231
+ return b.select.length === 0 && !b.values && b.from.length === 0 && b.where === null && (b.frontier ?? null) === null && (b.depth ?? null) === null
232
+ && b.follow === null && b.flatten.length === 0 && b.groupBy === null && b.having === null && (b.orderBy === null || b.orderBy.length === 0)
233
+ && b.limit === null && b.offset === null;
234
+ }
235
+
181
236
  function follow(f: Follow): Out {
182
237
  const dests = join(f.destinations.map((d) => (d.kind === "op" ? op(d) : expr(d))), ", ");
183
238
  return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
184
239
  }
185
240
 
241
+ // `as <name>` is always written (0.25), as every select item and group key is
242
+ // aliased: the tree does not record whether it was, and the name is what the
243
+ // later clauses read.
244
+ function flatten(f: Flatten): Out {
245
+ return cat("flatten ", expr(f.expr), ` as ${f.name}`);
246
+ }
247
+
186
248
  // Every key aliased, as every select item is (`layer: layer`).
187
249
  function groupBy(g: GroupBy): Out {
188
250
  return cat("group by ", join(g.keys.map(key), ", "));