querylens 0.1.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -0
  3. package/dist/bin/querylens.js +208 -0
  4. package/dist/src/engine/bufferTrace.js +67 -0
  5. package/dist/src/engine/datasets.js +139 -0
  6. package/dist/src/engine/exec/delete.js +95 -0
  7. package/dist/src/engine/exec/evaluate.js +174 -0
  8. package/dist/src/engine/exec/index.js +4 -0
  9. package/dist/src/engine/exec/insert.js +75 -0
  10. package/dist/src/engine/exec/operators.js +1290 -0
  11. package/dist/src/engine/exec/run.js +35 -0
  12. package/dist/src/engine/exec/sort.js +171 -0
  13. package/dist/src/engine/exec/unique.js +79 -0
  14. package/dist/src/engine/exec/update.js +124 -0
  15. package/dist/src/engine/exec/writeScan.js +88 -0
  16. package/dist/src/engine/explain.js +114 -0
  17. package/dist/src/engine/index/btree.js +481 -0
  18. package/dist/src/engine/index/build.js +99 -0
  19. package/dist/src/engine/index/bulk.js +107 -0
  20. package/dist/src/engine/index/display.js +38 -0
  21. package/dist/src/engine/index/index.js +9 -0
  22. package/dist/src/engine/index/lookup.js +213 -0
  23. package/dist/src/engine/index/rangeLookup.js +158 -0
  24. package/dist/src/engine/index/spec.js +47 -0
  25. package/dist/src/engine/index/unique.js +31 -0
  26. package/dist/src/engine/index/validate.js +105 -0
  27. package/dist/src/engine/index.js +16 -0
  28. package/dist/src/engine/locks/index.js +1 -0
  29. package/dist/src/engine/locks/lockManager.js +46 -0
  30. package/dist/src/engine/parser/ast.js +77 -0
  31. package/dist/src/engine/parser/display.js +404 -0
  32. package/dist/src/engine/parser/index.js +4 -0
  33. package/dist/src/engine/parser/parser.js +1108 -0
  34. package/dist/src/engine/parser/print.js +74 -0
  35. package/dist/src/engine/parser/tokenizer.js +146 -0
  36. package/dist/src/engine/planner/buildPlan.js +208 -0
  37. package/dist/src/engine/planner/cost.js +582 -0
  38. package/dist/src/engine/planner/emit.js +267 -0
  39. package/dist/src/engine/planner/emitDelete.js +57 -0
  40. package/dist/src/engine/planner/emitUpdate.js +51 -0
  41. package/dist/src/engine/planner/index.js +8 -0
  42. package/dist/src/engine/planner/joinOrder.js +252 -0
  43. package/dist/src/engine/planner/optimize.js +906 -0
  44. package/dist/src/engine/planner/plan.js +445 -0
  45. package/dist/src/engine/predict.js +120 -0
  46. package/dist/src/engine/runQuery.js +393 -0
  47. package/dist/src/engine/seed.js +165 -0
  48. package/dist/src/engine/stats.js +118 -0
  49. package/dist/src/engine/storage/bufferPool.js +194 -0
  50. package/dist/src/engine/storage/index.js +3 -0
  51. package/dist/src/engine/storage/page.js +46 -0
  52. package/dist/src/engine/storage/policy.js +360 -0
  53. package/dist/src/engine/subquery.js +88 -0
  54. package/dist/src/engine/trace.js +17 -0
  55. package/dist/src/engine/types.js +39 -0
  56. package/dist/src/engine/value.js +80 -0
  57. package/dist/src/engine/viewState.js +187 -0
  58. package/package.json +40 -0
@@ -0,0 +1,74 @@
1
+ /** The canonical key an aggregate's value is stored under — `COUNT(*)`, `SUM(age)`, `SUM(t.age)`. */
2
+ export function aggregateKeyOf(fn, arg) {
3
+ return `${fn}(${arg.kind === 'star' ? '*' : arg.table ? `${arg.table}.${arg.name}` : arg.name})`;
4
+ }
5
+ /**
6
+ * How tightly a node binds, for parenthesising: OR < AND < NOT < a predicate <
7
+ * `+ -` < `* / %` < unary minus < an atom. (plan.md §25.4 B1.)
8
+ */
9
+ function precedence(expr) {
10
+ switch (expr.kind) {
11
+ case 'or':
12
+ return 1;
13
+ case 'and':
14
+ return 2;
15
+ case 'not':
16
+ return 3;
17
+ case 'compare':
18
+ case 'isNull':
19
+ case 'in':
20
+ case 'like':
21
+ return 4;
22
+ case 'arith':
23
+ return expr.op === '+' || expr.op === '-' ? 5 : 6;
24
+ case 'neg':
25
+ return 7;
26
+ case 'column':
27
+ case 'literal':
28
+ case 'aggregate':
29
+ return 8;
30
+ }
31
+ }
32
+ /**
33
+ * SQL text for `expr`, parenthesised exactly where the tree's shape needs it,
34
+ * so it re-parses to the same tree — and so it can serve as a result column's
35
+ * name (`SELECT price * qty` is a column called `price * qty`).
36
+ */
37
+ export function exprToSql(expr) {
38
+ const at = (child, min) => precedence(child) < min ? `(${exprToSql(child)})` : exprToSql(child);
39
+ switch (expr.kind) {
40
+ case 'column':
41
+ return expr.table ? `${expr.table}.${expr.name}` : expr.name;
42
+ case 'literal':
43
+ return expr.raw;
44
+ case 'aggregate':
45
+ return aggregateKeyOf(expr.fn, expr.arg);
46
+ case 'compare':
47
+ return `${at(expr.left, 5)} ${expr.op} ${at(expr.right, 5)}`;
48
+ case 'and':
49
+ return `${at(expr.left, 2)} AND ${at(expr.right, 3)}`;
50
+ case 'or':
51
+ return `${at(expr.left, 1)} OR ${at(expr.right, 2)}`;
52
+ case 'not':
53
+ return `NOT ${at(expr.operand, 3)}`;
54
+ case 'isNull':
55
+ return `${at(expr.operand, 5)} IS ${expr.negated ? 'NOT ' : ''}NULL`;
56
+ case 'in':
57
+ return expr.subquery
58
+ ? `${at(expr.operand, 5)} ${expr.negated ? 'NOT ' : ''}IN (SELECT ${expr.subquery.column.name} FROM ${expr.subquery.from.name}${expr.subquery.where ? ` WHERE ${exprToSql(expr.subquery.where)}` : ''})`
59
+ : `${at(expr.operand, 5)} ${expr.negated ? 'NOT ' : ''}IN (${expr.items.map((i) => at(i, 5)).join(', ')})`;
60
+ case 'like':
61
+ return `${at(expr.operand, 5)} ${expr.negated ? 'NOT ' : ''}LIKE ${at(expr.pattern, 5)}`;
62
+ case 'arith': {
63
+ // Left-associative: the right operand of a same-level operator needs parentheses (`a - (b - c)`).
64
+ const p = precedence(expr);
65
+ return `${at(expr.left, p)} ${expr.op} ${at(expr.right, p + 1)}`;
66
+ }
67
+ case 'neg': {
68
+ // `- -x` would read as a comment, and `-(a + b)` needs its parentheses.
69
+ const inner = expr.operand;
70
+ const wrap = inner.kind === 'neg' || (inner.kind === 'literal' && inner.raw.startsWith('-'));
71
+ return wrap ? `-(${exprToSql(inner)})` : `-${at(inner, 7)}`;
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Hand-written tokenizer. Every token carries its character span so the parser
3
+ * can point CodeMirror at the exact text that went wrong.
4
+ */
5
+ /**
6
+ * Includes keywords v0 does NOT support. Recognising them is what lets the
7
+ * parser say "GROUP BY is not supported yet" instead of "unexpected identifier".
8
+ */
9
+ export const KEYWORDS = new Set([
10
+ 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'AS', 'NULL', 'TRUE', 'FALSE', 'BETWEEN', 'IN', 'IS', 'LIKE',
11
+ 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'OUTER', 'FULL', 'CROSS', 'ON', 'USING',
12
+ 'GROUP', 'ORDER', 'BY', 'ASC', 'DESC', 'HAVING', 'LIMIT', 'OFFSET', 'DISTINCT', 'UNION',
13
+ 'INSERT', 'UPDATE', 'DELETE', 'INTO', 'VALUES', 'SET', 'CREATE', 'DROP', 'EXPLAIN', 'ANALYZE',
14
+ ]);
15
+ export class TokenizeError extends Error {
16
+ from;
17
+ to;
18
+ hint;
19
+ constructor(message, from, to, hint) {
20
+ super(message);
21
+ this.name = 'TokenizeError';
22
+ this.from = from;
23
+ this.to = to;
24
+ if (hint !== undefined)
25
+ this.hint = hint;
26
+ }
27
+ }
28
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\r';
29
+ const isDigit = (c) => c >= '0' && c <= '9';
30
+ const isIdentStart = (c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
31
+ const isIdentPart = (c) => isIdentStart(c) || isDigit(c);
32
+ const SINGLE = {
33
+ '*': 'star',
34
+ // Tokenised, not supported: it lets the parser explain qualified names
35
+ // rather than failing at the character level before it sees the real problem.
36
+ '.': 'dot',
37
+ ',': 'comma',
38
+ '(': 'lparen',
39
+ ')': 'rparen',
40
+ ';': 'semicolon',
41
+ '=': 'operator',
42
+ '<': 'operator',
43
+ '>': 'operator',
44
+ // Arithmetic (plan.md §25.4 B1b). `*` is `star` above: the parser reads it as
45
+ // multiplication wherever an operand has just been parsed, and as "every column" elsewhere.
46
+ '+': 'operator',
47
+ '-': 'operator',
48
+ '/': 'operator',
49
+ '%': 'operator',
50
+ };
51
+ export function tokenize(sql) {
52
+ const tokens = [];
53
+ let i = 0;
54
+ while (i < sql.length) {
55
+ const c = sql[i];
56
+ if (isSpace(c)) {
57
+ i++;
58
+ continue;
59
+ }
60
+ // -- line comment
61
+ if (c === '-' && sql[i + 1] === '-') {
62
+ while (i < sql.length && sql[i] !== '\n')
63
+ i++;
64
+ continue;
65
+ }
66
+ // /* block comment */
67
+ if (c === '/' && sql[i + 1] === '*') {
68
+ const start = i;
69
+ i += 2;
70
+ while (i < sql.length && !(sql[i] === '*' && sql[i + 1] === '/'))
71
+ i++;
72
+ if (i >= sql.length) {
73
+ throw new TokenizeError('Unterminated block comment.', start, sql.length);
74
+ }
75
+ i += 2;
76
+ continue;
77
+ }
78
+ const start = i;
79
+ if (isIdentStart(c)) {
80
+ while (i < sql.length && isIdentPart(sql[i]))
81
+ i++;
82
+ const value = sql.slice(start, i);
83
+ const upper = value.toUpperCase();
84
+ tokens.push({
85
+ type: KEYWORDS.has(upper) ? 'keyword' : 'identifier',
86
+ value,
87
+ upper,
88
+ from: start,
89
+ to: i,
90
+ });
91
+ continue;
92
+ }
93
+ if (isDigit(c)) {
94
+ while (i < sql.length && isDigit(sql[i]))
95
+ i++;
96
+ if (sql[i] === '.') {
97
+ i++;
98
+ while (i < sql.length && isDigit(sql[i]))
99
+ i++;
100
+ }
101
+ const value = sql.slice(start, i);
102
+ tokens.push({ type: 'number', value, upper: value, from: start, to: i });
103
+ continue;
104
+ }
105
+ if (c === "'") {
106
+ i++;
107
+ let text = '';
108
+ for (;;) {
109
+ if (i >= sql.length) {
110
+ throw new TokenizeError('Unterminated string literal.', start, sql.length, "Add a closing ' quote.");
111
+ }
112
+ if (sql[i] === "'") {
113
+ // '' is an escaped quote inside a string.
114
+ if (sql[i + 1] === "'") {
115
+ text += "'";
116
+ i += 2;
117
+ continue;
118
+ }
119
+ i++;
120
+ break;
121
+ }
122
+ text += sql[i];
123
+ i++;
124
+ }
125
+ tokens.push({ type: 'string', value: text, upper: text, from: start, to: i });
126
+ continue;
127
+ }
128
+ // Two-character operators are tokenised so the parser can reject them with
129
+ // a message about v0 scope rather than a confusing character-level error.
130
+ const two = sql.slice(i, i + 2);
131
+ if (two === '<=' || two === '>=' || two === '<>' || two === '!=') {
132
+ i += 2;
133
+ tokens.push({ type: 'operator', value: two, upper: two, from: start, to: i });
134
+ continue;
135
+ }
136
+ const single = SINGLE[c];
137
+ if (single) {
138
+ i++;
139
+ tokens.push({ type: single, value: c, upper: c, from: start, to: i });
140
+ continue;
141
+ }
142
+ throw new TokenizeError(`Unexpected character ${JSON.stringify(c)}.`, start, start + 1);
143
+ }
144
+ tokens.push({ type: 'eof', value: '', upper: '', from: sql.length, to: sql.length });
145
+ return tokens;
146
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * AST → canonical logical plan, built bottom-up:
3
+ *
4
+ * Limit n -- only when there is a LIMIT
5
+ * └─ Project [columns]
6
+ * └─ Sort column [ASC|DESC] -- only when there is an ORDER BY
7
+ * └─ Filter (predicate) -- only when there is a WHERE
8
+ * └─ SeqScan table
9
+ *
10
+ * This is the *unoptimised* shape on purpose. It is a direct, obvious
11
+ * translation of the query text, which is what makes the optimizer's rewrite
12
+ * legible when the two are shown side by side.
13
+ */
14
+ import { exprToSql } from "../parser/print.js";
15
+ import { aggregateKeyOf, columnKey, selectItemArgColumn, selectItemKey, walkExpr } from "../parser/index.js";
16
+ import { indexNameOf, joinIndexFor } from "../index/spec.js";
17
+ /**
18
+ * A `DELETE`'s canonical plan is just the scan that finds the rows to
19
+ * remove — `Filter → SeqScan`, or a bare `SeqScan` with no `WHERE`. No
20
+ * `Project`: a delete produces no columns for a client to read.
21
+ */
22
+ export function buildDeletePlan(ast) {
23
+ const scan = { op: 'SeqScan', table: ast.from.name };
24
+ return ast.where ? { op: 'Filter', predicate: ast.where, child: scan } : scan;
25
+ }
26
+ /**
27
+ * An `UPDATE`'s canonical plan is the same shape as a `DELETE`'s — the scan
28
+ * that finds the rows to modify. The SET clause plays no part in planning:
29
+ * it only takes effect once execution has already found a row.
30
+ */
31
+ export function buildUpdatePlan(ast) {
32
+ const scan = { op: 'SeqScan', table: ast.table.name };
33
+ return ast.where ? { op: 'Filter', predicate: ast.where, child: scan } : scan;
34
+ }
35
+ /** Whether this SELECT needs a `HashAggregate`: it has `GROUP BY`, or any aggregate call in its list. */
36
+ export function isAggregateQuery(ast) {
37
+ return (ast.groupBy !== undefined ||
38
+ ast.having !== undefined ||
39
+ (ast.select.kind === 'columns' && ast.select.items.some((item) => item.kind === 'aggregate')));
40
+ }
41
+ /**
42
+ * Every aggregate the query needs computed per group: the SELECT list's, then any
43
+ * that only a HAVING predicate mentions (`SELECT dept … HAVING COUNT(*) > 2` needs a
44
+ * count nobody selected), each once, keyed by its canonical text.
45
+ */
46
+ export function aggregateSpecsOf(ast) {
47
+ const specs = new Map();
48
+ if (ast.select.kind === 'columns') {
49
+ for (const item of ast.select.items) {
50
+ if (item.kind !== 'aggregate')
51
+ continue;
52
+ const key = selectItemKey(item);
53
+ specs.set(key, { key, fn: item.fn, column: selectItemArgColumn(item) });
54
+ }
55
+ }
56
+ if (ast.having) {
57
+ walkExpr(ast.having, (e) => {
58
+ if (e.kind !== 'aggregate')
59
+ return;
60
+ const key = aggregateKeyOf(e.fn, e.arg);
61
+ if (!specs.has(key))
62
+ specs.set(key, { key, fn: e.fn, column: e.arg.kind === 'star' ? null : columnKey(e.arg) });
63
+ });
64
+ }
65
+ return [...specs.values()];
66
+ }
67
+ /**
68
+ * Builds the aggregate node — `HashAggregate` by default, or `SortAggregate`
69
+ * when `strategy` says so — for a `SELECT` that groups or aggregates. Shared
70
+ * with `emit.ts`'s progressive plan-tree narration, so the two never drift
71
+ * on how a `SelectItem` becomes an `AggregateSpec`. Nothing in the pipeline
72
+ * picks `strategy` on its own; it defaults to `'hash'` (the shipped
73
+ * behaviour) and only `/compare`'s third mode ever passes `'sort'`, via
74
+ * `EngineOptions.aggregateStrategy` (plan.md §22.2).
75
+ */
76
+ export function buildAggregatePlan(ast, child, strategy = 'hash') {
77
+ const groupBy = ast.groupBy?.columns.map((c) => c.name) ?? [];
78
+ const aggregates = aggregateSpecsOf(ast);
79
+ return { op: strategy === 'sort' ? 'SortAggregate' : 'HashAggregate', groupBy, aggregates, child };
80
+ }
81
+ /**
82
+ * The algorithm a `JOIN` will actually run. Three of them work on any pair of tables; `index-nested-loop`
83
+ * (plan.md §25.4 B4) needs the inner table to have a B+Tree on the join column, so without one it cannot run
84
+ * and the join is an ordinary `nested-loop` — never an error, and never silent: `emit.ts` says so in the plan.
85
+ */
86
+ export function effectiveJoinAlgorithm(requested, joinTable, rightColumn) {
87
+ if (requested !== 'index-nested-loop')
88
+ return requested;
89
+ // An index whose *leading* column is the join column will do — a composite one that starts with it is entered
90
+ // through that one-column leftmost prefix.
91
+ return joinTable && joinIndexFor(joinTable, rightColumn) ? requested : 'nested-loop';
92
+ }
93
+ /**
94
+ * Builds the whole `Join` chain for `FROM t1 JOIN t2 ON … [JOIN t3 ON …]…` (plan.md §25.4 C3 slice b) — left-deep,
95
+ * one `Join` node per clause, each nesting the previous one as its `left`. A bare `SeqScan` starts the fold, and
96
+ * each step adds a bare `SeqScan` (or, for `index-nested-loop`, an `IndexProbe`) on the one table its own clause
97
+ * introduces — v1 never pushes a predicate into any scan here, plan.md §22.2 — with the ON equality's two columns
98
+ * resolved to whichever side is the new table and whichever is a table already in the chain, whichever order they
99
+ * were written in. `checkJoinOn`/`checkJoinChain` already guarantee this at parse time; the `kind` check below is
100
+ * just how the type system is told what that already ensures. Shared with `emit.ts`'s progressive plan-tree
101
+ * narration, like `buildAggregatePlan` already is, so the two never drift. `algorithm` defaults to `'nested-loop'`
102
+ * (the shipped behaviour); only `/compare`'s join mode ever passes another, via `EngineOptions.joinStrategy` — the
103
+ * same one for every step, though `index-nested-loop` still falls back per step (see `effectiveJoinAlgorithm`)
104
+ * whenever that step's own table has no usable index. For a single join this is exactly the old two-table shape.
105
+ */
106
+ export function buildJoinPlan(joins, fromTable, algorithm = 'nested-loop', joinTables) {
107
+ let plan = { op: 'SeqScan', table: fromTable };
108
+ for (const join of joins) {
109
+ const { left, right } = join.on;
110
+ if (left.kind !== 'column' || right.kind !== 'column') {
111
+ throw new Error('unreachable: checkJoinOn guarantees a JOIN ON is column = column');
112
+ }
113
+ const newTable = join.table.name;
114
+ // checkJoinChain already guarantees exactly one side names newTable and the other a table already in scope.
115
+ const newTableIsLeftOperand = left.table === newTable;
116
+ const rightColumn = newTableIsLeftOperand ? left.name : right.name;
117
+ const leftColumn = newTableIsLeftOperand ? right.name : left.name;
118
+ const leftTable = (newTableIsLeftOperand ? right.table : left.table);
119
+ const joinTable = joinTables?.[newTable];
120
+ const effective = effectiveJoinAlgorithm(algorithm, joinTable, rightColumn);
121
+ plan = {
122
+ op: 'Join',
123
+ algorithm: effective,
124
+ ...(join.joinType ? { joinType: join.joinType } : {}),
125
+ left: plan,
126
+ right: effective === 'index-nested-loop'
127
+ ? {
128
+ op: 'IndexProbe',
129
+ table: newTable,
130
+ // The index's name: the join column itself for a plain index, the composite's columns joined for one that leads with it.
131
+ column: indexNameOf(joinIndexFor(joinTable, rightColumn).columns),
132
+ outerTable: leftTable,
133
+ outerColumn: leftColumn,
134
+ }
135
+ : { op: 'SeqScan', table: newTable },
136
+ leftTable,
137
+ rightTable: newTable,
138
+ leftColumn,
139
+ rightColumn,
140
+ };
141
+ }
142
+ return plan;
143
+ }
144
+ /**
145
+ * The `Project` list for a SELECT list. Plain columns and aggregates are read
146
+ * off the row by name; as soon as any item is *computed* (plan.md §25.4 B1b) the
147
+ * whole list becomes expressions — each item is then evaluated, a plain column
148
+ * just being the simplest expression. Shared with `emit.ts`'s narration so the
149
+ * two never disagree.
150
+ */
151
+ export function projectListFor(select) {
152
+ if (select.kind === 'star')
153
+ return { kind: 'star' };
154
+ if (!select.items.some((item) => item.kind === 'expression')) {
155
+ return { kind: 'columns', names: select.items.map(selectItemKey) };
156
+ }
157
+ return {
158
+ kind: 'expressions',
159
+ items: select.items.map((item) => {
160
+ const expr = item.kind === 'expression'
161
+ ? item.expr
162
+ : { kind: 'column', name: item.kind === 'column' ? item.name : selectItemKey(item), ...(item.kind === 'column' && item.table ? { table: item.table } : {}), span: item.span };
163
+ const key = selectItemKey(item);
164
+ return { key, expr, sql: item.kind === 'expression' && item.alias ? `${exprToSql(item.expr)} AS ${item.alias}` : key };
165
+ }),
166
+ };
167
+ }
168
+ /**
169
+ * The `DISTINCT` node above the projection (plan.md §25.4 B2): a `HashDistinct` by
170
+ * default, a `SortDistinct` when `aggregateStrategy` says `'sort'` — the same
171
+ * option `GROUP BY` reads, so `/compare` can force each side. A sort-based one
172
+ * also needs the query's `ORDER BY` column (the parser has checked it is in the
173
+ * select list): it sorts on that first, so the order the caller asked for survives.
174
+ * Shared with `emit.ts`'s narration.
175
+ */
176
+ export function distinctNodeFor(ast, child, strategy) {
177
+ if (strategy !== 'sort')
178
+ return { op: 'HashDistinct', child };
179
+ return {
180
+ op: 'SortDistinct',
181
+ ...(ast.orderBy ? { sortKey: { column: ast.orderBy.column, direction: ast.orderBy.direction } } : {}),
182
+ child,
183
+ };
184
+ }
185
+ export function buildPlan(ast, aggregateStrategy = 'hash', joinStrategy = 'nested-loop', joinTables) {
186
+ const scan = ast.joins && ast.joins.length > 0
187
+ ? buildJoinPlan(ast.joins, ast.from.name, joinStrategy, joinTables)
188
+ : { op: 'SeqScan', table: ast.from.name };
189
+ const filtered = ast.where
190
+ ? { op: 'Filter', predicate: ast.where, child: scan }
191
+ : scan;
192
+ const aggregated = isAggregateQuery(ast)
193
+ ? buildAggregatePlan(ast, filtered, aggregateStrategy)
194
+ : filtered;
195
+ // HAVING runs after the aggregate, on the groups it produced.
196
+ const having = ast.having ? { op: 'Having', predicate: ast.having, child: aggregated } : aggregated;
197
+ // ORDER BY and GROUP BY are mutually exclusive (the parser enforces this),
198
+ // so Sort only ever sits above the filtered scan, never above an aggregate.
199
+ const sorted = ast.orderBy
200
+ ? { op: 'Sort', column: ast.orderBy.column, direction: ast.orderBy.direction, child: having }
201
+ : having;
202
+ const columns = projectListFor(ast.select);
203
+ const projected = { op: 'Project', columns, child: sorted };
204
+ const distinct = ast.distinct ? distinctNodeFor(ast, projected, aggregateStrategy) : projected;
205
+ return ast.limit === undefined
206
+ ? distinct
207
+ : { op: 'Limit', count: ast.limit.value, ...(ast.limit.offset ? { offset: ast.limit.offset } : {}), child: distinct };
208
+ }