@omgbase/oqx 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +29 -0
  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 +62 -14
  13. package/dist/engine.js.map +1 -1
  14. package/dist/optimize/analysis.d.ts.map +1 -1
  15. package/dist/optimize/analysis.js +9 -0
  16. package/dist/optimize/analysis.js.map +1 -1
  17. package/dist/optimize/ir.d.ts +4 -1
  18. package/dist/optimize/ir.d.ts.map +1 -1
  19. package/dist/optimize/ir.js +1 -0
  20. package/dist/optimize/ir.js.map +1 -1
  21. package/dist/optimize/rules.d.ts.map +1 -1
  22. package/dist/optimize/rules.js +3 -2
  23. package/dist/optimize/rules.js.map +1 -1
  24. package/dist/parser.d.ts.map +1 -1
  25. package/dist/parser.js +73 -10
  26. package/dist/parser.js.map +1 -1
  27. package/dist/print.d.ts.map +1 -1
  28. package/dist/print.js +9 -0
  29. package/dist/print.js.map +1 -1
  30. package/dist/references.d.ts.map +1 -1
  31. package/dist/references.js +39 -1
  32. package/dist/references.js.map +1 -1
  33. package/dist/resolve.d.ts.map +1 -1
  34. package/dist/resolve.js +9 -2
  35. package/dist/resolve.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/dist/walk.d.ts +1 -1
  39. package/dist/walk.d.ts.map +1 -1
  40. package/dist/walk.js +5 -0
  41. package/dist/walk.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/ast.ts +20 -1
  44. package/src/build.ts +5 -3
  45. package/src/engine.ts +60 -14
  46. package/src/optimize/analysis.ts +6 -0
  47. package/src/optimize/ir.ts +5 -1
  48. package/src/optimize/rules.ts +3 -2
  49. package/src/parser.ts +72 -11
  50. package/src/print.ts +11 -2
  51. package/src/references.ts +34 -2
  52. package/src/resolve.ts +10 -3
  53. package/src/version.ts +1 -1
  54. package/src/walk.ts +6 -0
package/src/engine.ts CHANGED
@@ -139,6 +139,8 @@ const HARD_DEPTH_CAP = 8;
139
139
  type Projection = Pick<Subquery, "select" | "values">;
140
140
  // The grouping clauses of a body (0.24). Both `Query` and `Subquery` carry them.
141
141
  type Grouping = Pick<Subquery, "groupBy" | "having">;
142
+ // The `flatten` clauses of a body (0.25). Both `Query` and `Subquery` carry them.
143
+ type Flattening = Pick<Subquery, "flatten">;
142
144
 
143
145
  // The `$key: expr` item of a projection, if any — the identity the rows it
144
146
  // yields carry (SEMANTICS §16, since 0.20).
@@ -182,18 +184,19 @@ export class InMemoryEngine implements Engine {
182
184
  const src = this.coerceRows(this.evalRaw(query.source, root), query.source);
183
185
  const rows = this.reproject(src.rows, query.from, root);
184
186
  // One object as the source yields one record (SEMANTICS §3) — unless a
185
- // `from` re-projection or a walk fans it out.
186
- const single = src.single && query.from.length === 0 && !query.follow;
187
+ // `from` re-projection, a walk or a `flatten` fans it out.
188
+ const single = src.single && query.from.length === 0 && !query.follow && query.flatten.length === 0;
187
189
 
188
190
  const bound = this.boundOf(query, root);
189
191
  if (query.follow) return this.runFollow(query, rows, root, bound);
190
192
 
191
193
  // Consumer-directed short-circuits (skipped under `distinct`, which must
192
- // materialize + dedup by projection before reducing, and under `group by`,
193
- // whose consumer counts groups). Counting never materializes rows;
194
- // exists/none stop as soon as the bound is known to be non-empty (the
195
- // (offset+1)th match — or the first, when unbounded).
196
- if (!query.distinct && !query.groupBy && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
194
+ // materialize + dedup by projection before reducing, under `group by`,
195
+ // whose consumer counts groups, and under `flatten`, whose consumer counts
196
+ // flattened rows). Counting never materializes rows; exists/none stop as
197
+ // soon as the bound is known to be non-empty (the (offset+1)th match — or
198
+ // the first, when unbounded).
199
+ if (!query.distinct && !query.groupBy && query.flatten.length === 0 && (query.consumer === "exists" || query.consumer === "none" || query.consumer === "count")) {
197
200
  const need = query.consumer === "count" ? Infinity : bound.offset + 1;
198
201
  let n = 0;
199
202
  for (const r of rows) {
@@ -208,7 +211,7 @@ export class InMemoryEngine implements Engine {
208
211
  // first over an unordered, non-distinct set needs only the rows up to the
209
212
  // bound (offset + 1). single materializes everything so its error can
210
213
  // report how many rows actually matched.
211
- const cap = query.consumer === "first" && !query.orderBy && !query.distinct && !query.groupBy
214
+ const cap = query.consumer === "first" && !query.orderBy && !query.distinct && !query.groupBy && query.flatten.length === 0
212
215
  ? bound.offset + Math.min(1, bound.limit ?? 1)
213
216
  : Infinity;
214
217
  let kept: Scope[] = [];
@@ -219,6 +222,7 @@ export class InMemoryEngine implements Engine {
219
222
  if (kept.length >= cap) break;
220
223
  }
221
224
  }
225
+ if (query.flatten.length > 0) kept = this.flattenScopes(kept, query);
222
226
  if (query.groupBy) kept = this.groupScopes(kept, query, root);
223
227
  this.applyKey(kept, query);
224
228
  this.sortScopes(kept, query.orderBy);
@@ -329,6 +333,44 @@ export class InMemoryEngine implements Engine {
329
333
  return s;
330
334
  }
331
335
 
336
+ // ---- flatten (SEMANTICS §25) -----------------------------------------------
337
+
338
+ // Each `flatten` clause, in writing order, fans the scopes out: the expression
339
+ // is read in the row's scope (as `where` is) and its value split — an array
340
+ // (or a host collection) → one scope per element in collection order, so an
341
+ // empty collection yields none; absent → one scope with the name absent;
342
+ // anything else (an object, a scalar, a range) → one scope with the value.
343
+ // A flattened scope IS the parent scope — the same row, parent, lifts and
344
+ // intrinsics, so `$it`, `^` and `$depth` are unchanged — with the name bound
345
+ // beside the lifts (step 4 of §2: it shadows a same-named row property) and
346
+ // `$key` the pair `[parent identity, element identity]` (§16): an element's
347
+ // identity is the host's (an entry's key), else its structural value.
348
+ private flattenScopes(scopes: Scope[], body: Flattening): Scope[] {
349
+ for (const f of body.flatten) {
350
+ const out: Scope[] = [];
351
+ for (const s of scopes) {
352
+ const parentKey = this.scopeIdentity(s);
353
+ for (const elem of this.flattenElements(this.evalRaw(f.expr, s))) {
354
+ const key = [parentKey, elem == null ? null : this.hostIdentity(elem)];
355
+ out.push({ ...s, lifts: { ...(s.lifts ?? {}), [f.name]: elem }, meta: { ...(s.meta ?? {}), [KEY]: key } });
356
+ }
357
+ }
358
+ scopes = out;
359
+ }
360
+ return scopes;
361
+ }
362
+
363
+ // The elements a flattened value splits into (§25): §3's coercion with one
364
+ // softening — a scalar is one element, not an error, since no rows are
365
+ // sourced from it — and absent as one absent element rather than none.
366
+ private flattenElements(v: unknown): unknown[] {
367
+ if (v == null) return [undefined];
368
+ if (typeof v !== "object" || isRange(v) || isEntry(v)) return [v];
369
+ if (Array.isArray(v)) return v;
370
+ const single = this.ctx.isSingle ? this.ctx.isSingle(v) : !(Symbol.iterator in v);
371
+ return single ? [v] : Array.from(this.ctx.toRows(v));
372
+ }
373
+
332
374
  // ---- grouping (SEMANTICS §24) ----------------------------------------------
333
375
 
334
376
  // The kept scopes of a body, one GROUP scope per distinct key tuple, in order
@@ -425,6 +467,7 @@ export class InMemoryEngine implements Engine {
425
467
  const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
426
468
  let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
427
469
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
470
+ if (query.flatten.length > 0) scopes = this.flattenScopes(scopes, query);
428
471
  if (query.groupBy) scopes = this.groupScopes(scopes, query, root);
429
472
  this.sortScopes(scopes, query.orderBy);
430
473
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
@@ -649,10 +692,11 @@ export class InMemoryEngine implements Engine {
649
692
  // rows, and the consumer only needs their count after the bound.
650
693
  n = boundedCount(acc.rows.length, bound);
651
694
  scope.run.trace?.({ kind: "cardinality", count: n });
652
- } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy) {
695
+ } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy && op.sub.flatten.length === 0) {
653
696
  // Unbounded exists/none: stop at the first match (dedup cannot change
654
- // emptiness). Bounded, or grouped (`having` may empty the groups): the
655
- // offset/limit decide emptiness, so materialize.
697
+ // emptiness). Bounded, grouped (`having` may empty the groups) or
698
+ // flattened (an empty collection yields no row): the offset/limit
699
+ // decide emptiness, so materialize.
656
700
  n = this.anyMatch(op, scope, acc) ? 1 : 0;
657
701
  } else n = this.opRows(op, scope, bound, acc).length;
658
702
  if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
@@ -677,9 +721,10 @@ export class InMemoryEngine implements Engine {
677
721
  return this.opRows(op, scope, bound).length > 0;
678
722
  }
679
723
 
680
- // The rows a consumer op reduces: matched → grouped → ordered → distinct → bounded.
724
+ // The rows a consumer op reduces: matched → flattened → grouped → ordered → distinct → bounded.
681
725
  private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
682
726
  let scopes = this.matchRows(op, scope, acc);
727
+ if (op.sub.flatten.length > 0) scopes = this.flattenScopes(scopes, op.sub);
683
728
  if (op.sub.groupBy) scopes = this.groupScopes(scopes, op.sub, scope);
684
729
  this.applyKey(scopes, op.sub);
685
730
  this.sortScopes(scopes, op.sub.orderBy);
@@ -765,9 +810,9 @@ export class InMemoryEngine implements Engine {
765
810
  }
766
811
 
767
812
  // The plain scan: the rows, re-projected by `from`, with the whole `where`.
768
- // A `from` re-projection fans a single receiver out: the block is plural.
813
+ // A `from` re-projection or a `flatten` fans a single receiver out: the block is plural.
769
814
  private scan(src: Rows, op: OpNode, scope: Scope): Access {
770
- return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 };
815
+ return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 && op.sub.flatten.length === 0 };
771
816
  }
772
817
 
773
818
  private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
@@ -891,6 +936,7 @@ export class InMemoryEngine implements Engine {
891
936
  const { rows, where } = this.access(op, plan, scope);
892
937
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
893
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);
894
940
  if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
895
941
  this.sortScopes(scopes, sub.orderBy);
896
942
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
@@ -72,6 +72,8 @@ function subReadsScopeIn(sub: Subquery, at: number, upTo: number, destination: b
72
72
  if (sub.limit && exprReadsScopeIn(sub.limit, at, upTo)) return true;
73
73
  if (sub.offset && exprReadsScopeIn(sub.offset, at, upTo)) return true;
74
74
  if (sub.frontier && exprReadsScopeIn(sub.frontier, destination ? at - 1 : at, upTo)) return true;
75
+ // A `flatten` expression is read on each row (0.25); its block is an op read there.
76
+ if (sub.flatten.some((f) => exprReadsScopeIn(f.expr, at, upTo))) return true;
75
77
  // `group by` keys are read on the rows, `having` on the groups — both at `at` (0.24).
76
78
  if (sub.groupBy?.keys.some((k) => exprReadsScopeIn(k.expr, at, upTo))) return true;
77
79
  if (sub.having && whereReadsScopeIn(sub.having, at, upTo)) return true;
@@ -148,6 +150,9 @@ export function opRaiseFree(op: OpNode, ctx: RuleContext): boolean {
148
150
  if (!sub.from.every((e) => exprRaiseFree(e, ctx))) return false;
149
151
  if (sub.where && !whereRaiseFree(sub.where, ctx)) return false;
150
152
  if (sub.orderBy && !sub.orderBy.every((o) => exprRaiseFree(o.expr, ctx))) return false;
153
+ // A `flatten` value is coerced without error (a scalar is one row, §25), so
154
+ // the clause raises only when its expression does.
155
+ if (!sub.flatten.every((f) => exprRaiseFree(f.expr, ctx))) return false;
151
156
  return !opHasLifts(op);
152
157
  }
153
158
 
@@ -163,6 +168,7 @@ function subHasLifts(sub: Subquery): boolean {
163
168
  if (sub.select.some((item) => (item.kind === "field" ? item.lift > 0 : opHasLifts(item.op)))) return true;
164
169
  if (sub.where && whereHasLifts(sub.where)) return true;
165
170
  if (sub.having && whereHasLifts(sub.having)) return true;
171
+ if (sub.flatten.some((f) => f.expr.kind === "op" && opHasLifts(f.expr))) return true;
166
172
  if (sub.follow?.destinations.some((d) => d.kind === "op" && opHasLifts(d))) return true;
167
173
  return false;
168
174
  }
@@ -10,7 +10,7 @@
10
10
  // cannot be built, a receiver that is not stable) always degrade to the plain
11
11
  // scan the plan started from.
12
12
 
13
- import type { Consumer, Expr, Follow, GroupBy, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
13
+ import type { Consumer, Expr, Flatten, Follow, GroupBy, OpNode, OrderSpec, SelectItem, Where } from "../ast.ts";
14
14
 
15
15
  /** One hoisted equality `local == outer` from the block's top-level `&&`
16
16
  * conjunction: `local` reads only the block's row (an identifier or member
@@ -45,6 +45,9 @@ export interface BlockPlan {
45
45
  readonly distinct: boolean;
46
46
  readonly bound: { readonly limit: Expr | null; readonly offset: Expr | null };
47
47
  readonly follow: Follow | null;
48
+ /** `flatten` clauses (0.25): the matched rows are fanned out per element
49
+ * before the consumer sees them, so a consumer counts FLATTENED rows. */
50
+ readonly flatten: readonly Flatten[];
48
51
  /** `group by` / `having` (0.24): the matched rows are grouped before the
49
52
  * consumer sees them, so a consumer counts GROUPS, never rows. */
50
53
  readonly groupBy: GroupBy | null;
@@ -94,6 +97,7 @@ export function logicalBlock(node: OpNode, depth: number): BlockPlan {
94
97
  distinct: node.distinct,
95
98
  bound: { limit: sub.limit, offset: sub.offset },
96
99
  follow: sub.follow,
100
+ flatten: sub.flatten,
97
101
  groupBy: sub.groupBy,
98
102
  having: sub.having,
99
103
  correlated: [],
@@ -113,7 +113,8 @@ export const invariantBlock: Rule = (plan) => {
113
113
  // Precondition. A where-position `exists`, `none` or `count` without `distinct`
114
114
  // (dedup projects every row), without `order by` (`count` evaluates the keys),
115
115
  // without `follow`, without `group by` (the consumer counts groups, not rows —
116
- // 0.24), and with nothing left to evaluate per row: either the block has no
116
+ // 0.24), without `flatten` (the consumer counts flattened rows — 0.25), and
117
+ // with nothing left to evaluate per row: either the block has no
117
118
  // `where`, or every conjunct is correlated (so the probe's bucket is the
118
119
  // matched set).
119
120
  //
@@ -128,7 +129,7 @@ export const invariantBlock: Rule = (plan) => {
128
129
  export const cardinalityOnly: Rule = (plan) => {
129
130
  if (plan.fromCardinality) return null;
130
131
  if (plan.consumer !== "exists" && plan.consumer !== "none" && plan.consumer !== "count") return null;
131
- if (plan.distinct || plan.orderBy || plan.follow || plan.groupBy) return null;
132
+ if (plan.distinct || plan.orderBy || plan.follow || plan.groupBy || plan.flatten.length > 0) return null;
132
133
  if (plan.where !== null && plan.residual !== null) return null;
133
134
  return { ...plan, fromCardinality: true };
134
135
  };
package/src/parser.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  // consumer block — each clause appears at most once, in exactly this order:
14
14
  //
15
15
  // select <projection> from <source> where <predicate> follow <dest>, … {…}
16
- // order by … limit N offset N
16
+ // flatten <expr> [{…}] [as <name>] … group by … having … order by … limit N offset N
17
17
  //
18
18
  // A `follow` destination is a relation of the current row (a receiver, never
19
19
  // an outer reference) or a destination block — a receiver (which MAY be an
@@ -83,7 +83,7 @@ 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, GroupBy, GroupKey, AggregateName,
86
+ BinaryOp, Span, TemplatePart, Flatten, GroupBy, GroupKey, AggregateName,
87
87
  } from "./ast.ts";
88
88
  import { AGGREGATE_NAMES, isAggregateName } from "./ast.ts";
89
89
  import { resolveSelect, resolveWhere } from "./resolve.ts";
@@ -96,8 +96,11 @@ const CONSUMERS = new Set<string>(["collect", "exists", "none", "count", "first"
96
96
  // `depth` is legal only in a follow destination block, and the order sentence
97
97
  // names it only there. `group by` and `having` (0.24) sit after `follow`: the
98
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;
99
+ // grouped, and the bound and the ordering then apply to the groups. `flatten`
100
+ // (0.25) sits between them: it fans the rows a body yields — the occurrences,
101
+ // after a walk — out per element, and is the one clause that may repeat (each
102
+ // `flatten` fans out the rows the previous produced, SEMANTICS §25).
103
+ const CLAUSE_ORDER = ["select", "from", "where", "frontier", "depth", "follow", "flatten", "group by", "having", "order by", "limit", "offset"] as const;
101
104
  type Clause = (typeof CLAUSE_ORDER)[number];
102
105
  const DESTINATION_ONLY: ReadonlySet<Clause> = new Set<Clause>(["depth"]);
103
106
  // The clauses a body may hold, in order.
@@ -109,7 +112,7 @@ function clausesOf(ctx: BodyCtx): Clause[] {
109
112
  const CLAUSE_WORDS = new Set<string>([
110
113
  "collect", "exists", "none", "count", "first", "single",
111
114
  "order", "by", "asc", "desc", "follow", "distinct", "frontier", "depth", "in", "values",
112
- "limit", "offset", "group", "having",
115
+ "limit", "offset", "group", "having", "flatten", "as",
113
116
  ]);
114
117
  // The names a group scope resolves besides its keys (SEMANTICS §24): the
115
118
  // group's rows, the key record itself, and its identity.
@@ -158,6 +161,7 @@ interface BodyClauses {
158
161
  follow: Follow | null;
159
162
  frontier: Expr | null;
160
163
  depth: number | null;
164
+ flatten: Flatten[];
161
165
  groupBy: GroupBy | null;
162
166
  having: Where | null;
163
167
  distinct: boolean;
@@ -273,6 +277,7 @@ class Parser {
273
277
  consumer: "collect",
274
278
  follow: body.follow,
275
279
  frontier: body.frontier,
280
+ flatten: body.flatten,
276
281
  groupBy: body.groupBy,
277
282
  having: body.having,
278
283
  distinct: body.distinct,
@@ -287,7 +292,7 @@ class Parser {
287
292
  private queryFromOp(e: OpNode, start: number): Query {
288
293
  return {
289
294
  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,
295
+ orderBy: e.sub.orderBy, consumer: e.op, follow: e.sub.follow, frontier: e.sub.frontier, flatten: e.sub.flatten, groupBy: e.sub.groupBy, having: e.sub.having,
291
296
  distinct: e.distinct, values: e.sub.values, limit: e.sub.limit, offset: e.sub.offset,
292
297
  };
293
298
  }
@@ -310,6 +315,7 @@ class Parser {
310
315
  let follow: Follow | null = null;
311
316
  let frontier: Expr | null = null;
312
317
  let depth: number | null = null;
318
+ const flatten: Flatten[] = [];
313
319
  let groupBy: GroupBy | null = null;
314
320
  let having: Where | null = null;
315
321
  let distinct = false;
@@ -324,7 +330,9 @@ class Parser {
324
330
 
325
331
  const enter = (clause: Clause): void => {
326
332
  const idx = CLAUSE_ORDER.indexOf(clause);
327
- if (idx === stage) this.fail(`duplicate \`${clause}\` clause`);
333
+ // `flatten` is the one clause that may repeat (0.25): each fans out the
334
+ // rows the previous produced.
335
+ if (idx === stage && clause !== "flatten") this.fail(`duplicate \`${clause}\` clause`);
328
336
  if (idx < stage) {
329
337
  this.fail(`\`${clause}\` must come before \`${CLAUSE_ORDER[stage]}\` — OQX clause order is ${clausesOf(ctx).join(", ")}`);
330
338
  }
@@ -377,6 +385,14 @@ class Parser {
377
385
  follow = this.atDepth(rowDepth, () => this.parseFollow());
378
386
  continue;
379
387
  }
388
+ // `flatten <expr> [{ … }] [as <name>]` (0.25): the value is read on each
389
+ // row, like `where`; a brace after a receiver is the receiver-block sugar
390
+ // (the `collect` op), so the block needs no slot of its own.
391
+ if (this.atFlatten()) {
392
+ enter("flatten");
393
+ flatten.push(this.atDepth(rowDepth, () => this.parseFlatten(flatten)));
394
+ continue;
395
+ }
380
396
  // `group by k, name: expr` (0.24): the keys are read on the rows, like `where`.
381
397
  if (this.atGroupBy()) {
382
398
  enter("group by");
@@ -451,11 +467,52 @@ class Parser {
451
467
  }
452
468
  if (groupBy) {
453
469
  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 });
470
+ this.checkGroupedBody({ select, resolvedSelect, having, resolvedHaving, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy });
455
471
  } else {
456
- this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, limit, offset, groupBy: null });
472
+ this.rejectAggregates({ select, resolvedSelect, having: null, resolvedHaving: null, orderBy, froms, where: resolvedWhere, frontier, flatten, limit, offset, groupBy: null });
473
+ }
474
+ return { froms, where, select, orderBy, follow, frontier, depth, flatten, groupBy, having, distinct, values, limit, offset };
475
+ }
476
+
477
+ // ---- flatten (0.25) -------------------------------------------------------
478
+
479
+ // `flatten` is a clause when a value can follow the word; otherwise it is a
480
+ // field name (`select flatten from r`).
481
+ private atFlatten(): boolean {
482
+ if (!this.at("ident", "flatten")) return false;
483
+ const nx = this.peekAt(1);
484
+ // A value start — or an aggregate call whose name is a consumer word
485
+ // (`flatten collect(x)`), admitted so the aggregate check can name it.
486
+ return this.startsValue(nx) || (nx?.type === "ident" && isAggregateName(nx.value) && this.peekAt(2)?.type === "lparen");
487
+ }
488
+
489
+ // `flatten <expr> [as <name>]`. The expression is any value — a block after a
490
+ // receiver is read by the postfix grammar as the implicit `collect` op (0.20),
491
+ // which is exactly the block over the coerced collection SEMANTICS §25 wants.
492
+ // The name defaults to a plain navigation's last segment (as a select item's
493
+ // or a group key's does); anything else needs `as`. Names are unique within
494
+ // the body, never a `$`-name (the language's), a keyword or a literal word.
495
+ private parseFlatten(earlier: readonly Flatten[]): Flatten {
496
+ const start = this.start();
497
+ this.next(); // `flatten`
498
+ const expr = this.parseValueExpr();
499
+ let name: string;
500
+ if (this.at("ident", "as")) {
501
+ this.next();
502
+ const t = this.peek();
503
+ if (t.type !== "ident") this.fail("expected a name after `as` (`flatten <expr> as <name>`)");
504
+ if (LITERAL_WORDS.has(t.value)) this.fail(`\`${t.value}\` is a literal and cannot name a flattened value (\`flatten <expr> as <name>\`)`);
505
+ if (t.value.startsWith("$")) this.fail(`a flattened value cannot be bound to a \`$\`-name (\`${t.value}\`) — \`$\` belongs to the language; choose another name`);
506
+ name = this.next().value;
507
+ } else {
508
+ const key = navKey(expr);
509
+ if (key === null) {
510
+ this.failAt(start, `\`flatten ${describe(expr)}\` needs a name (\`flatten ${describe(expr)} as <name>\`) — only a plain navigation names itself by its last segment`);
511
+ }
512
+ name = key;
457
513
  }
458
- return { froms, where, select, orderBy, follow, frontier, depth, groupBy, having, distinct, values, limit, offset };
514
+ if (earlier.some((f) => f.name === name)) this.failAt(start, `duplicate flatten name '${name}' — each flatten binds its own name (\`flatten <expr> as <other>\`)`);
515
+ return { kind: "flatten", span: this.sp(start), expr, name };
459
516
  }
460
517
 
461
518
  // ---- grouping (0.24) ------------------------------------------------------
@@ -555,6 +612,7 @@ class Parser {
555
612
  for (const a of where) found.push([a, "`where`"]);
556
613
  collect(b.froms, "`from`");
557
614
  collect([b.frontier], "`frontier`");
615
+ collect(b.flatten.map((f) => f.expr), "`flatten`");
558
616
  collect([b.limit], "`limit`");
559
617
  collect([b.offset], "`offset`");
560
618
  if (b.groupBy) collect(b.groupBy.keys.map((k) => k.expr), "a `group by` key");
@@ -713,6 +771,7 @@ class Parser {
713
771
  }
714
772
  if (t.value === "depth") this.fail("expected an integer after `depth` (`depth 1`..`depth 8`)");
715
773
  if (t.value === "frontier") this.fail(`unexpected 'frontier' after \`${last}\` — \`frontier\` takes an expression (\`frontier <expr>\`) and belongs to a body that has a \`follow\``);
774
+ if (t.value === "flatten") this.fail(`unexpected 'flatten' after \`${last}\` — \`flatten\` takes a value to split into rows (\`flatten <expr> [{ … }] [as <name>]\`)`);
716
775
  if (t.value === "group") this.fail(`unexpected 'group' after \`${last}\` — a grouping is written \`group by <key>[, <key>…]\` (a key is a name or \`alias: <expr>\`)`);
717
776
  if (t.value === "having") this.fail(`unexpected 'having' after \`${last}\` — \`having\` takes a predicate over each group (\`having count() > 1\`) and belongs to a body that has a \`group by\``);
718
777
  }
@@ -1247,6 +1306,7 @@ class Parser {
1247
1306
  follow: body.follow,
1248
1307
  frontier: body.frontier,
1249
1308
  depth: body.depth,
1309
+ flatten: body.flatten,
1250
1310
  groupBy: body.groupBy,
1251
1311
  having: body.having,
1252
1312
  values: body.values,
@@ -1307,7 +1367,7 @@ class Parser {
1307
1367
  this.next();
1308
1368
  const subSpan: Span = [lbracket, this.end()];
1309
1369
  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,
1370
+ kind: "subquery", span: subSpan, from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, flatten: [], groupBy: null, having: null, values: false, limit: null, offset: null, ...parts,
1311
1371
  });
1312
1372
  const bang = this.atOp("!") ? this.next() : null;
1313
1373
  if (index) {
@@ -1603,6 +1663,7 @@ interface GroupCheck {
1603
1663
  froms: Expr[];
1604
1664
  where: Where | null;
1605
1665
  frontier: Expr | null;
1666
+ flatten: readonly Flatten[];
1606
1667
  limit: Expr | null;
1607
1668
  offset: Expr | null;
1608
1669
  groupBy: GroupBy | 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, GroupBy, GroupKey, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
15
+ import type { AstNode, Expr, Flatten, Follow, GroupBy, GroupKey, OpNode, OrderSpec, Query, SelectItem, Subquery, Where } from "./ast.ts";
16
16
  import { OqxError } from "./errors.ts";
17
17
 
18
18
  /** The template form of a tree: `strings` has `count + 1` fragments; the i-th gap
@@ -83,6 +83,7 @@ function render(n: AstNode): Out {
83
83
  case "subquery": return block(n);
84
84
  case "op": return op(n);
85
85
  case "follow": return follow(n);
86
+ case "flatten": return flatten(n);
86
87
  case "groupBy": return groupBy(n);
87
88
  case "key": return key(n);
88
89
  case "order": return order(n);
@@ -117,7 +118,7 @@ function query(q: Query): Out {
117
118
  return cat(expr(q.source), ` ${q.consumer}${q.distinct ? " distinct" : ""} `, braces(body(q)));
118
119
  }
119
120
 
120
- type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
121
+ type Body = Pick<Subquery, "select" | "values" | "from" | "where" | "follow" | "flatten" | "groupBy" | "having" | "orderBy" | "limit" | "offset">
121
122
  & Partial<Pick<Subquery, "frontier" | "depth">>;
122
123
 
123
124
  // The clauses after `from` (a query) or after the projection (a block): the
@@ -130,6 +131,7 @@ function tail(b: Omit<Body, "select" | "values">): Out[] {
130
131
  if (b.frontier) out.push(cat("frontier ", expr(b.frontier)));
131
132
  if (b.depth != null) out.push([`depth ${number(b.depth)}`]);
132
133
  if (b.follow) out.push(follow(b.follow));
134
+ for (const f of b.flatten) out.push(flatten(f));
133
135
  if (b.groupBy) out.push(groupBy(b.groupBy));
134
136
  if (b.having) out.push(cat("having ", where(b.having)));
135
137
  if (b.orderBy && b.orderBy.length > 0) out.push(cat("order by ", join(b.orderBy.map(order), ", ")));
@@ -183,6 +185,13 @@ function follow(f: Follow): Out {
183
185
  return cat(`follow${f.distinct ? " distinct" : ""} `, dests);
184
186
  }
185
187
 
188
+ // `as <name>` is always written (0.25), as every select item and group key is
189
+ // aliased: the tree does not record whether it was, and the name is what the
190
+ // later clauses read.
191
+ function flatten(f: Flatten): Out {
192
+ return cat("flatten ", expr(f.expr), ` as ${f.name}`);
193
+ }
194
+
186
195
  // Every key aliased, as every select item is (`layer: layer`).
187
196
  function groupBy(g: GroupBy): Out {
188
197
  return cat("group by ", join(g.keys.map(key), ", "));
package/src/references.ts CHANGED
@@ -25,7 +25,11 @@
25
25
  // (`target.contains("x")` references `target`); a name a nested block
26
26
  // LIFTS onto a scope (`^open: value`, SEMANTICS §19) is a binding of that
27
27
  // scope, so reading it there (`select open`, `^open` from deeper) is not a
28
- // reference either;
28
+ // reference either — and neither is a name a `flatten` bound (0.25,
29
+ // SEMANTICS §25) in the clauses after it: a later `flatten`, `group by`,
30
+ // `having`, the select, `order by`, or `^name` from a block under them; in
31
+ // its own expression and before the clause (`where`, `frontier`, `follow`)
32
+ // the name is still the row's property;
29
33
  // • a `select` alias is not a reference where the language resolves it
30
34
  // (SEMANTICS §14): in the same body's `where`, and in the items to its
31
35
  // right — a bare identifier naming such an alias, or a chain headed by it,
@@ -54,7 +58,7 @@
54
58
  // are cut after the last step at its scope's depth. The empty list is the root
55
59
  // scope.
56
60
 
57
- import type { AstNode, Expr, Follow, GroupBy, Query, SelectItem, Span, Subquery } from "./ast.ts";
61
+ import type { AstNode, Expr, Flatten, Follow, GroupBy, Query, SelectItem, Span, Subquery } from "./ast.ts";
58
62
  import type { Clause } from "./walk.ts";
59
63
  import { visit } from "./walk.ts";
60
64
 
@@ -122,6 +126,9 @@ export function references(root: Query | Subquery): Reference[] {
122
126
  const levels = head.kind === "outer" ? head.levels : 0;
123
127
  const depth = ctx.depth - levels;
124
128
  if (isLiftBound(name, depth, ctx.path, lifts)) return false;
129
+ // A name a `flatten` bound (0.25) is an alias of the expression already
130
+ // reported in clause `flatten`, read in the clauses after it.
131
+ if (isFlattenBound(name, depth, ctx.path, node)) return false;
125
132
  // At a group scope (0.24) a bare key name is the key — an alias of the
126
133
  // expression reported in clause `groupBy` — and `rows` is the language's
127
134
  // value; neither reads a row.
@@ -191,6 +198,31 @@ function liftsLandingOn(body: Query | Subquery): ReadonlySet<string> {
191
198
  return names;
192
199
  }
193
200
 
201
+ /**
202
+ * Whether `name`, read at scope `depth`, is bound there by a `flatten` of the
203
+ * body whose rows are at `depth` (SEMANTICS §25) — the position lies in a
204
+ * clause evaluated after that flatten: a later `flatten`, `group by`, `having`,
205
+ * the select, or `order by` (`where`, `frontier`, `follow` and the body's
206
+ * sources run before any flatten, and a flatten's own expression before itself).
207
+ */
208
+ function isFlattenBound(name: string, depth: number, path: readonly AstNode[], node: AstNode): boolean {
209
+ const i = bodyIndexAt(path, depth);
210
+ if (i === -1) return false;
211
+ const body = path[i] as Query | Subquery;
212
+ if (body.flatten.length === 0) return false;
213
+ const child = path[i + 1] ?? node;
214
+ let upto: number;
215
+ if (body.select.includes(child as SelectItem) || child === body.groupBy || child === body.having || (body.orderBy?.includes(child as never) ?? false)) {
216
+ upto = body.flatten.length;
217
+ } else {
218
+ const k = body.flatten.indexOf(child as Flatten);
219
+ if (k === -1) return false;
220
+ upto = k;
221
+ }
222
+ for (let j = 0; j < upto; j++) if (body.flatten[j]!.name === name) return true;
223
+ return false;
224
+ }
225
+
194
226
  /**
195
227
  * Whether `name`, read at scope `depth`, is a value of a GROUP scope there
196
228
  * (SEMANTICS §24): the body whose rows are at `depth` has a `group by`, the
package/src/resolve.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  // given. Untouched nodes are returned by reference, so a cached query keeps the
27
27
  // identity of every block the rewrite did not reach.
28
28
 
29
- import type { Expr, Follow, FollowDestination, GroupBy, OpNode, Query, SelectItem, Subquery, Where } from "./ast.ts";
29
+ import type { Expr, Flatten, Follow, FollowDestination, GroupBy, OpNode, Query, SelectItem, Subquery, Where } from "./ast.ts";
30
30
  import { OqxError } from "./errors.ts";
31
31
 
32
32
  /** How a resolution failure is reported: the parser raises its positioned parse
@@ -61,6 +61,13 @@ function resolveBody<T extends Query | Subquery>(node: T, fail: Fail): T {
61
61
  // The keys are read on the rows, not rewritten (a key IS the name the post-group
62
62
  // clauses read); their nested blocks resolve against their own select.
63
63
  const groupBy = node.groupBy === null ? null : resolveGroupBy(node.groupBy, fail);
64
+ // A `flatten` expression (0.25) reads the row, not the projection — like
65
+ // `order by` and `frontier` it is not rewritten against the select; its
66
+ // nested blocks resolve against their own select.
67
+ const flatten = mapSame(node.flatten, (f: Flatten) => {
68
+ const expr = resolveExpr(f.expr, fail);
69
+ return expr === f.expr ? f : { ...f, expr };
70
+ });
64
71
  const from = mapSame(node.from, (e) => resolveExpr(e, fail));
65
72
  const orderBy = node.orderBy === null ? null : mapSame(node.orderBy, (o) => {
66
73
  const expr = resolveExpr(o.expr, fail);
@@ -75,10 +82,10 @@ function resolveBody<T extends Query | Subquery>(node: T, fail: Fail): T {
75
82
  const source = node.kind === "query" ? resolveExpr(node.source, fail) : null;
76
83
  const same = where2 === node.where && select === node.select && follow === node.follow && from === node.from
77
84
  && orderBy === node.orderBy && limit === node.limit && offset === node.offset && frontier === node.frontier
78
- && groupBy === node.groupBy && having2 === node.having
85
+ && groupBy === node.groupBy && having2 === node.having && flatten === node.flatten
79
86
  && (node.kind !== "query" || source === node.source);
80
87
  if (same) return node;
81
- const out = { ...node, where: where2, select, follow, frontier, groupBy, having: having2, from, orderBy, limit, offset };
88
+ const out = { ...node, where: where2, select, follow, frontier, flatten, groupBy, having: having2, from, orderBy, limit, offset };
82
89
  if (node.kind === "query") (out as Query).source = source!;
83
90
  return out;
84
91
  }
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.24";
4
+ export const LANGUAGE_VERSION = "0.25";
package/src/walk.ts CHANGED
@@ -15,6 +15,7 @@ import { LANGUAGE_VERSION } from "./version.ts";
15
15
  export type Clause =
16
16
  | "source" | "from" | "where" | "select" | "orderBy" | "limit" | "offset"
17
17
  | "follow" | "follow.destination" | "follow.frontier"
18
+ | "flatten" // a `flatten` node and its expression (0.25)
18
19
  | "groupBy" | "having"; // the `group by` keys and the post-group predicate (0.24)
19
20
 
20
21
  /** Where a visited node sits. `depth` is the scope depth at which the node is
@@ -58,6 +59,9 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
58
59
  // The body's `frontier` (0.20) reads each seed in the row scope, like `where`.
59
60
  { key: "frontier", clause: "follow.frontier", depth: 1 },
60
61
  { key: "follow", clause: "follow", depth: 1 },
62
+ // A `flatten` expression is read on each row, like `where` (0.25); a
63
+ // flattened row sits at the same depth as its parent.
64
+ { key: "flatten", clause: "flatten", depth: 1 },
61
65
  // `group by` keys are read on the rows, `having` on the groups — both at
62
66
  // the row depth (the group scope sits where the rows did; 0.24).
63
67
  { key: "groupBy", clause: "groupBy", depth: 1 },
@@ -76,6 +80,7 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
76
80
  // (`childDepth` tells the two apart by the block's position).
77
81
  { key: "frontier", clause: "follow.frontier", depth: -1 },
78
82
  { key: "follow", clause: "follow" },
83
+ { key: "flatten", clause: "flatten" },
79
84
  { key: "groupBy", clause: "groupBy" },
80
85
  { key: "having", clause: "having" },
81
86
  { key: "orderBy", clause: "orderBy" },
@@ -84,6 +89,7 @@ export const CHILDREN: { readonly [K in AstKind]: readonly ChildKey[] } = {
84
89
  ],
85
90
  op: [{ key: "receiver" }, { key: "sub", depth: 1 }],
86
91
  follow: [{ key: "destinations", clause: "follow.destination" }],
92
+ flatten: [{ key: "expr" }],
87
93
  groupBy: [{ key: "keys" }],
88
94
  key: [{ key: "expr" }],
89
95
  order: [{ key: "expr" }],