@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.
- package/CHANGELOG.md +6 -0
- package/README.md +29 -0
- package/dist/ast.d.ts +19 -1
- package/dist/ast.d.ts.map +1 -1
- package/dist/ast.js.map +1 -1
- package/dist/build.d.ts +3 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +4 -2
- package/dist/build.js.map +1 -1
- package/dist/engine.d.ts +2 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +62 -14
- package/dist/engine.js.map +1 -1
- package/dist/optimize/analysis.d.ts.map +1 -1
- package/dist/optimize/analysis.js +9 -0
- package/dist/optimize/analysis.js.map +1 -1
- package/dist/optimize/ir.d.ts +4 -1
- package/dist/optimize/ir.d.ts.map +1 -1
- package/dist/optimize/ir.js +1 -0
- package/dist/optimize/ir.js.map +1 -1
- package/dist/optimize/rules.d.ts.map +1 -1
- package/dist/optimize/rules.js +3 -2
- package/dist/optimize/rules.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +73 -10
- package/dist/parser.js.map +1 -1
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +9 -0
- package/dist/print.js.map +1 -1
- package/dist/references.d.ts.map +1 -1
- package/dist/references.js +39 -1
- package/dist/references.js.map +1 -1
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +9 -2
- package/dist/resolve.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts +1 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +5 -0
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +20 -1
- package/src/build.ts +5 -3
- package/src/engine.ts +60 -14
- package/src/optimize/analysis.ts +6 -0
- package/src/optimize/ir.ts +5 -1
- package/src/optimize/rules.ts +3 -2
- package/src/parser.ts +72 -11
- package/src/print.ts +11 -2
- package/src/references.ts +34 -2
- package/src/resolve.ts +10 -3
- package/src/version.ts +1 -1
- 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
|
|
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,
|
|
193
|
-
// whose consumer counts groups
|
|
194
|
-
//
|
|
195
|
-
// (offset+1)th match — or
|
|
196
|
-
|
|
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,
|
|
655
|
-
//
|
|
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);
|
package/src/optimize/analysis.ts
CHANGED
|
@@ -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
|
}
|
package/src/optimize/ir.ts
CHANGED
|
@@ -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: [],
|
package/src/optimize/rules.ts
CHANGED
|
@@ -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),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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" }],
|