@omgbase/oqx 0.25.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omgbase/oqx",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "OQX (omgbase Query eXpressions): a composable query language and engine for JavaScript collections",
5
5
  "keywords": [
6
6
  "query",
package/src/engine.ts CHANGED
@@ -676,7 +676,6 @@ export class InMemoryEngine implements Engine {
676
676
  }
677
677
 
678
678
  private evalWhereOp(op: OpNode, scope: Scope): boolean {
679
- if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
680
679
  const plan = this.planOf(op, scope);
681
680
  if (!plan.invariant) return this.evalWhereOpPlanned(op, plan, scope);
682
681
  return this.memoized(op, scope, () => this.evalWhereOpPlanned(op, plan, scope));
@@ -687,7 +686,12 @@ export class InMemoryEngine implements Engine {
687
686
  if (op.op === "exists" || op.op === "none" || op.op === "count") {
688
687
  const acc = this.access(op, plan, scope);
689
688
  let n: number;
690
- if (plan.fromCardinality && acc.where === null) {
689
+ // A block with a `follow` (legal in every body since 0.26) yields its
690
+ // walk's occurrences: no cardinality answer, no first-match stop — the
691
+ // walk is run and counted like any other rows.
692
+ if (op.sub.follow) {
693
+ n = this.opRows(op, scope, bound, acc).length;
694
+ } else if (plan.fromCardinality && acc.where === null) {
691
695
  // Nothing left to evaluate per row: the accessed rows ARE the matched
692
696
  // rows, and the consumer only needs their count after the bound.
693
697
  n = boundedCount(acc.rows.length, bound);
@@ -721,14 +725,28 @@ export class InMemoryEngine implements Engine {
721
725
  return this.opRows(op, scope, bound).length > 0;
722
726
  }
723
727
 
724
- // The rows a consumer op reduces: matched → flattened → grouped → ordered → distinct → bounded.
725
- private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
726
- let scopes = this.matchRows(op, scope, acc);
727
- if (op.sub.flatten.length > 0) scopes = this.flattenScopes(scopes, op.sub);
728
- if (op.sub.groupBy) scopes = this.groupScopes(scopes, op.sub, scope);
729
- this.applyKey(scopes, op.sub);
730
- this.sortScopes(scopes, op.sub.orderBy);
731
- if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
728
+ // The rows a consumer op reduces — in every position (a where-position test,
729
+ // a select-position directive, a destination block): matched (or, with a
730
+ // `follow`, the walk's occurrences over the matched seeds) → flattened →
731
+ // grouped → ordered → distinct → bounded. `destination` marks a follow
732
+ // destination block: its `frontier` is the destination's (read by the outer
733
+ // walk), never a seed frontier for a `follow` of its own.
734
+ private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access, destination = false): Scope[] {
735
+ const sub = op.sub;
736
+ let scopes: Scope[];
737
+ if (sub.follow) {
738
+ // The seeds are the matched rows themselves (`followWalk` enters them); a
739
+ // follow block is never correlated, so `where` here is the whole predicate.
740
+ const { rows, where } = acc ?? this.access(op, this.planOf(op, scope), scope);
741
+ const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
742
+ scopes = this.followWalk(this.keyed(seeds, sub, scope), sub.follow, destination ? null : sub.frontier, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
743
+ } else scopes = this.matchRows(op, scope, acc);
744
+ if (sub.flatten.length > 0) scopes = this.flattenScopes(scopes, sub);
745
+ if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
746
+ // An occurrence's `$key` is the walk's identity (`keyed` gave the seeds theirs).
747
+ if (!sub.follow) this.applyKey(scopes, sub);
748
+ this.sortScopes(scopes, sub.orderBy);
749
+ if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
732
750
  return sliceBound(scopes, bound);
733
751
  }
734
752
 
@@ -928,24 +946,10 @@ export class InMemoryEngine implements Engine {
928
946
  private evalCollectScopes(op: OpNode, scope: Scope, plan: BlockPlan = this.planOf(op, scope), destination = false): { scopes: Scope[]; single: boolean } {
929
947
  const sub = op.sub;
930
948
  const bound = this.boundOf(sub, scope);
931
- let scopes: Scope[];
932
- let single = false;
933
- if (sub.follow) {
934
- // The seeds are the matched rows themselves (`followWalk` enters them); a
935
- // follow block is never correlated, so `where` here is the whole predicate.
936
- const { rows, where } = this.access(op, plan, scope);
937
- const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
938
- scopes = this.followWalk(this.keyed(seeds, sub, scope), sub.follow, destination ? null : sub.frontier, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
939
- if (sub.flatten.length > 0) scopes = this.flattenScopes(scopes, sub);
940
- if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
941
- this.sortScopes(scopes, sub.orderBy);
942
- if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
943
- scopes = sliceBound(scopes, bound);
944
- } else {
945
- const acc = this.access(op, plan, scope);
946
- single = acc.single;
947
- scopes = this.opRows(op, scope, bound, acc);
948
- }
949
+ const acc = this.access(op, plan, scope);
950
+ // A walk is always plural (SEMANTICS §20).
951
+ const single = sub.follow ? false : acc.single;
952
+ const scopes = this.opRows(op, scope, bound, acc, destination);
949
953
  switch (op.op) {
950
954
  case "collect": case "count": return { scopes, single };
951
955
  case "first": return { scopes: scopes.slice(0, 1), single };
@@ -135,8 +135,9 @@ export function whereRaiseFree(w: Where, ctx: RuleContext): boolean {
135
135
 
136
136
  /** A where-position directive that can neither raise nor bind: `exists`,
137
137
  * `none` or `count` (never `single`, which raises on several rows; `collect`
138
- * binds lifts; `first`/`single` project), without `follow` (raises in where
139
- * position), without a bound (an invalid value raises), without `distinct`
138
+ * binds lifts; `first`/`single` project), without `follow` (a walk's
139
+ * destinations may raise, and a walk is never a cardinality answer), without a
140
+ * bound (an invalid value raises), without `distinct`
140
141
  * (dedup projects, and a projected range raises), whose receiver, `from`,
141
142
  * `where` and `order by` (evaluated by `count`) are raise-free and which lifts
142
143
  * nothing anywhere. */
package/src/parser.ts CHANGED
@@ -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
@@ -87,6 +97,7 @@ import type {
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"]);
@@ -170,6 +181,30 @@ interface BodyClauses {
170
181
  offset: Expr | null;
171
182
  }
172
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
+
173
208
  // A copy of a node with another span (a parenthesized operand takes the span of
174
209
  // its parentheses).
175
210
  function withSpan<T extends { span: Span }>(node: T, span: Span): T {
@@ -188,6 +223,11 @@ class Parser {
188
223
  * top-level row's clauses, one deeper per block body and per follow `where`.
189
224
  * An absolute reference `N^name` reads `depth − N` scopes out. */
190
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;
191
231
 
192
232
  constructor(tokens: Token[]) {
193
233
  this.tokens = tokens;
@@ -230,9 +270,10 @@ class Parser {
230
270
  const start = this.start();
231
271
  // Directive form: `<receiver> <consumer> { … }` consuming the whole query.
232
272
  let directive = this.tryOp(false);
233
- if (directive && !this.at("eof") && (this.at("lbracket") || this.atValueDirective())) {
273
+ if (directive && !this.at("eof") && (this.at("lbracket") || this.atBlock() !== null)) {
234
274
  // A longer chain whose outermost node is still a directive
235
- // (`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).
236
277
  const save = this.pos;
237
278
  try {
238
279
  const e = this.parsePostfix(directive);
@@ -264,7 +305,7 @@ class Parser {
264
305
  const body = this.parseBody(TOP_CTX);
265
306
  if (!this.at("eof")) this.fail(`unexpected ${this.tokDesc()} after the query — nothing may follow the last clause`);
266
307
  if (body.froms.length === 0) {
267
- 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)");
268
309
  }
269
310
  return {
270
311
  kind: "query",
@@ -350,7 +391,12 @@ class Parser {
350
391
  if (this.at("kw", "from")) {
351
392
  enter("from");
352
393
  this.next();
353
- 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; }
354
400
  continue;
355
401
  }
356
402
  if (this.at("kw", "where")) {
@@ -980,7 +1026,7 @@ class Parser {
980
1026
  for (const it of items) {
981
1027
  if (it.name === "") {
982
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 { … }`");
983
- 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)");
984
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 }`)");
985
1031
  }
986
1032
  const key = `${"^".repeat(it.kind === "field" ? it.lift : 0)}${it.name}`;
@@ -1009,8 +1055,9 @@ class Parser {
1009
1055
  this.next(); // ':'
1010
1056
  const op = this.tryOp(false);
1011
1057
  if (op) {
1012
- // `name: jobs first { }.pay`: the directive is the head of a longer value.
1013
- 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);
1014
1061
  if (op.op !== "collect" && op.op !== "first" && op.op !== "single" && op.op !== "count") {
1015
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)`);
1016
1063
  }
@@ -1267,13 +1314,18 @@ class Parser {
1267
1314
  }
1268
1315
 
1269
1316
  // The cursor is at a consumer word that opens a block — `<op> {` or
1270
- // `<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.
1271
1323
  private atConsumerBlock(): Consumer | null {
1272
1324
  if (!this.at("ident") || !CONSUMERS.has(this.peek().value)) return null;
1273
1325
  const after = this.peekAt(1);
1274
1326
  const opThenBrace = after?.type === "lbrace";
1275
1327
  const opDistinctBrace = after?.type === "ident" && after.value === "distinct" && this.peekAt(2)?.type === "lbrace";
1276
- return opThenBrace || opDistinctBrace ? (this.peek().value as Consumer) : null;
1328
+ return opThenBrace || opDistinctBrace || this.bareDirectives ? (this.peek().value as Consumer) : null;
1277
1329
  }
1278
1330
 
1279
1331
  // `[<op>] [distinct] { <sub> }` over an already-parsed receiver; the cursor is
@@ -1285,20 +1337,32 @@ class Parser {
1285
1337
  if (this.at("ident") && CONSUMERS.has(this.peek().value)) this.next(); // the consumer word (absent for `x { … }`)
1286
1338
  let distinct = false;
1287
1339
  if (this.at("ident", "distinct")) { this.next(); distinct = true; }
1288
- const lbrace = this.next().pos; // '{'
1289
- const defer = this.deferLookup;
1290
- this.deferLookup = false;
1291
- // The receiver was read at the enclosing depth; the block's rows are one deeper.
1292
- const body = this.atDepth(this.depth + 1, () => this.parseBody({ top: false, op, liftsAllowed: inWhere && op === "collect", destination }));
1293
- this.deferLookup = defer;
1294
- if (!this.at("rbrace")) this.fail(`expected '}' to close the ${op} { … } block`);
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);
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
+ }
1299
1363
  const sub: Subquery = {
1300
1364
  kind: "subquery",
1301
- span: this.sp(lbrace),
1365
+ span: subSpan,
1302
1366
  from: body.froms,
1303
1367
  where: body.where,
1304
1368
  select: body.select,
@@ -1313,7 +1377,34 @@ class Parser {
1313
1377
  limit: body.limit,
1314
1378
  offset: body.offset,
1315
1379
  };
1316
- 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
+ };
1317
1408
  }
1318
1409
 
1319
1410
  // A tuple `(e1, e2, …)` at the cursor's `(` (0.20): two or more comma-separated
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
  //
@@ -19,8 +21,8 @@ import { OqxError } from "./errors.ts";
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;
@@ -96,26 +98,62 @@ function render(n: AstNode): Out {
96
98
  // ---- query / bodies ---------------------------------------------------------
97
99
 
98
100
  function query(q: Query): Out {
99
- // Body form for `collect` — unless the query carries body-level `from`
100
- // re-projections, which only a block can hold (a second top-level `from` is a
101
- // duplicate clause): then the directive form `source collect { … from E }`.
102
- if (q.consumer === "collect" && q.from.length === 0) {
103
- // `select` is always written — except for a leading bare item literally
104
- // named `distinct`, which the keyword would swallow as the modifier.
105
- const first = q.select[0];
106
- const bareDistinct = !q.distinct && first?.kind === "field" && first.name === "distinct"
107
- && first.expr.kind === "ident" && first.expr.name === "distinct";
108
- const clauses: Out[] = [];
109
- if (q.select.length > 0) {
110
- const items = projection(q.select, q.values);
111
- clauses.push(bareDistinct ? items : cat(`select ${q.distinct ? "distinct " : ""}`, items));
112
- }
113
- clauses.push(cat("from ", expr(q.source)));
114
- clauses.push(...tail(q));
115
- 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));
121
+ }
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;
116
140
  }
117
- // Directive form: `<source> <consumer> [distinct] { body }`.
118
- return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
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);
147
+ }
148
+
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" : ""} `;
119
157
  }
120
158
 
121
159
  type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "flatten" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
@@ -174,12 +212,27 @@ function order(o: OrderSpec): Out {
174
212
  function op(o: OpNode): Out {
175
213
  return cat(
176
214
  receiver(o.receiver),
177
- ` ${o.op}${o.distinct ? " distinct" : ""} `,
178
- block(o.sub),
215
+ directive(o.op, o.distinct, o.receiver, o.sub),
179
216
  o.countCmp ? ` ${o.countCmp.op} ${number(o.countCmp.value)}` : "",
180
217
  );
181
218
  }
182
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
+
183
236
  function follow(f: Follow): Out {
184
237
  const dests = join(f.destinations.map((d) => (d.kind === "op" ? op(d) : expr(d))), ", ");
185
238
  return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /** The OQX language version (`spec/oqx/VERSION`, `major.minor`) this implementation
2
2
  * conforms to — distinct from the package's own semver, whose `major.minor` it
3
3
  * equals. Stamped on every `toJSON` document. */
4
- export const LANGUAGE_VERSION = "0.25";
4
+ export const LANGUAGE_VERSION = "0.26";