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.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/bin/querylens.js +208 -0
- package/dist/src/engine/bufferTrace.js +67 -0
- package/dist/src/engine/datasets.js +139 -0
- package/dist/src/engine/exec/delete.js +95 -0
- package/dist/src/engine/exec/evaluate.js +174 -0
- package/dist/src/engine/exec/index.js +4 -0
- package/dist/src/engine/exec/insert.js +75 -0
- package/dist/src/engine/exec/operators.js +1290 -0
- package/dist/src/engine/exec/run.js +35 -0
- package/dist/src/engine/exec/sort.js +171 -0
- package/dist/src/engine/exec/unique.js +79 -0
- package/dist/src/engine/exec/update.js +124 -0
- package/dist/src/engine/exec/writeScan.js +88 -0
- package/dist/src/engine/explain.js +114 -0
- package/dist/src/engine/index/btree.js +481 -0
- package/dist/src/engine/index/build.js +99 -0
- package/dist/src/engine/index/bulk.js +107 -0
- package/dist/src/engine/index/display.js +38 -0
- package/dist/src/engine/index/index.js +9 -0
- package/dist/src/engine/index/lookup.js +213 -0
- package/dist/src/engine/index/rangeLookup.js +158 -0
- package/dist/src/engine/index/spec.js +47 -0
- package/dist/src/engine/index/unique.js +31 -0
- package/dist/src/engine/index/validate.js +105 -0
- package/dist/src/engine/index.js +16 -0
- package/dist/src/engine/locks/index.js +1 -0
- package/dist/src/engine/locks/lockManager.js +46 -0
- package/dist/src/engine/parser/ast.js +77 -0
- package/dist/src/engine/parser/display.js +404 -0
- package/dist/src/engine/parser/index.js +4 -0
- package/dist/src/engine/parser/parser.js +1108 -0
- package/dist/src/engine/parser/print.js +74 -0
- package/dist/src/engine/parser/tokenizer.js +146 -0
- package/dist/src/engine/planner/buildPlan.js +208 -0
- package/dist/src/engine/planner/cost.js +582 -0
- package/dist/src/engine/planner/emit.js +267 -0
- package/dist/src/engine/planner/emitDelete.js +57 -0
- package/dist/src/engine/planner/emitUpdate.js +51 -0
- package/dist/src/engine/planner/index.js +8 -0
- package/dist/src/engine/planner/joinOrder.js +252 -0
- package/dist/src/engine/planner/optimize.js +906 -0
- package/dist/src/engine/planner/plan.js +445 -0
- package/dist/src/engine/predict.js +120 -0
- package/dist/src/engine/runQuery.js +393 -0
- package/dist/src/engine/seed.js +165 -0
- package/dist/src/engine/stats.js +118 -0
- package/dist/src/engine/storage/bufferPool.js +194 -0
- package/dist/src/engine/storage/index.js +3 -0
- package/dist/src/engine/storage/page.js +46 -0
- package/dist/src/engine/storage/policy.js +360 -0
- package/dist/src/engine/subquery.js +88 -0
- package/dist/src/engine/trace.js +17 -0
- package/dist/src/engine/types.js +39 -0
- package/dist/src/engine/value.js +80 -0
- package/dist/src/engine/viewState.js +187 -0
- 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
|
+
}
|