@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/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);
@@ -633,7 +676,6 @@ export class InMemoryEngine implements Engine {
633
676
  }
634
677
 
635
678
  private evalWhereOp(op: OpNode, scope: Scope): boolean {
636
- if (op.sub.follow) throw new OqxError("`follow` is only valid on a select-position collect { … }, not a where op", "eval");
637
679
  const plan = this.planOf(op, scope);
638
680
  if (!plan.invariant) return this.evalWhereOpPlanned(op, plan, scope);
639
681
  return this.memoized(op, scope, () => this.evalWhereOpPlanned(op, plan, scope));
@@ -644,15 +686,21 @@ export class InMemoryEngine implements Engine {
644
686
  if (op.op === "exists" || op.op === "none" || op.op === "count") {
645
687
  const acc = this.access(op, plan, scope);
646
688
  let n: number;
647
- 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) {
648
695
  // Nothing left to evaluate per row: the accessed rows ARE the matched
649
696
  // rows, and the consumer only needs their count after the bound.
650
697
  n = boundedCount(acc.rows.length, bound);
651
698
  scope.run.trace?.({ kind: "cardinality", count: n });
652
- } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy) {
699
+ } else if (op.op !== "count" && bound === UNBOUNDED && !op.sub.groupBy && op.sub.flatten.length === 0) {
653
700
  // 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.
701
+ // emptiness). Bounded, grouped (`having` may empty the groups) or
702
+ // flattened (an empty collection yields no row): the offset/limit
703
+ // decide emptiness, so materialize.
656
704
  n = this.anyMatch(op, scope, acc) ? 1 : 0;
657
705
  } else n = this.opRows(op, scope, bound, acc).length;
658
706
  if (op.op === "count") return op.countCmp ? compareCount(n, op.countCmp) : n > 0;
@@ -677,13 +725,28 @@ export class InMemoryEngine implements Engine {
677
725
  return this.opRows(op, scope, bound).length > 0;
678
726
  }
679
727
 
680
- // The rows a consumer op reduces: matched → grouped → ordered → distinct → bounded.
681
- private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
682
- let scopes = this.matchRows(op, scope, acc);
683
- if (op.sub.groupBy) scopes = this.groupScopes(scopes, op.sub, scope);
684
- this.applyKey(scopes, op.sub);
685
- this.sortScopes(scopes, op.sub.orderBy);
686
- 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);
687
750
  return sliceBound(scopes, bound);
688
751
  }
689
752
 
@@ -765,9 +828,9 @@ export class InMemoryEngine implements Engine {
765
828
  }
766
829
 
767
830
  // 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.
831
+ // A `from` re-projection or a `flatten` fans a single receiver out: the block is plural.
769
832
  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 };
833
+ 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
834
  }
772
835
 
773
836
  private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
@@ -883,23 +946,10 @@ export class InMemoryEngine implements Engine {
883
946
  private evalCollectScopes(op: OpNode, scope: Scope, plan: BlockPlan = this.planOf(op, scope), destination = false): { scopes: Scope[]; single: boolean } {
884
947
  const sub = op.sub;
885
948
  const bound = this.boundOf(sub, scope);
886
- let scopes: Scope[];
887
- let single = false;
888
- if (sub.follow) {
889
- // The seeds are the matched rows themselves (`followWalk` enters them); a
890
- // follow block is never correlated, so `where` here is the whole predicate.
891
- const { rows, where } = this.access(op, plan, scope);
892
- const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
893
- 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 }));
894
- if (sub.groupBy) scopes = this.groupScopes(scopes, sub, scope);
895
- this.sortScopes(scopes, sub.orderBy);
896
- if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
897
- scopes = sliceBound(scopes, bound);
898
- } else {
899
- const acc = this.access(op, plan, scope);
900
- single = acc.single;
901
- scopes = this.opRows(op, scope, bound, acc);
902
- }
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);
903
953
  switch (op.op) {
904
954
  case "collect": case "count": return { scopes, single };
905
955
  case "first": return { scopes: scopes.slice(0, 1), single };
@@ -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;
@@ -133,8 +135,9 @@ export function whereRaiseFree(w: Where, ctx: RuleContext): boolean {
133
135
 
134
136
  /** A where-position directive that can neither raise nor bind: `exists`,
135
137
  * `none` or `count` (never `single`, which raises on several rows; `collect`
136
- * binds lifts; `first`/`single` project), without `follow` (raises in where
137
- * 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`
138
141
  * (dedup projects, and a projected range raises), whose receiver, `from`,
139
142
  * `where` and `order by` (evaluated by `count`) are raise-free and which lifts
140
143
  * nothing anywhere. */
@@ -148,6 +151,9 @@ export function opRaiseFree(op: OpNode, ctx: RuleContext): boolean {
148
151
  if (!sub.from.every((e) => exprRaiseFree(e, ctx))) return false;
149
152
  if (sub.where && !whereRaiseFree(sub.where, ctx)) return false;
150
153
  if (sub.orderBy && !sub.orderBy.every((o) => exprRaiseFree(o.expr, ctx))) return false;
154
+ // A `flatten` value is coerced without error (a scalar is one row, §25), so
155
+ // the clause raises only when its expression does.
156
+ if (!sub.flatten.every((f) => exprRaiseFree(f.expr, ctx))) return false;
151
157
  return !opHasLifts(op);
152
158
  }
153
159
 
@@ -163,6 +169,7 @@ function subHasLifts(sub: Subquery): boolean {
163
169
  if (sub.select.some((item) => (item.kind === "field" ? item.lift > 0 : opHasLifts(item.op)))) return true;
164
170
  if (sub.where && whereHasLifts(sub.where)) return true;
165
171
  if (sub.having && whereHasLifts(sub.having)) return true;
172
+ if (sub.flatten.some((f) => f.expr.kind === "op" && opHasLifts(f.expr))) return true;
166
173
  if (sub.follow?.destinations.some((d) => d.kind === "op" && opHasLifts(d))) return true;
167
174
  return false;
168
175
  }
@@ -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
  };