squirreling 0.8.0 → 0.9.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/package.json +3 -3
- package/src/execute/aggregates.js +18 -30
- package/src/execute/execute.js +22 -35
- package/src/execute/join.js +15 -31
- package/src/execute/sort.js +5 -10
- package/src/expression/evaluate.js +56 -60
- package/src/index.d.ts +24 -2
- package/src/index.js +2 -2
- package/src/parse/comparison.js +7 -7
- package/src/parse/expression.js +14 -14
- package/src/parse/functions.js +23 -6
- package/src/parse/parse.js +55 -68
- package/src/parse/state.js +0 -9
- package/src/parse/tokenize.js +2 -2
- package/src/parse/types.d.ts +1 -1
- package/src/parseErrors.js +5 -4
- package/src/plan/columns.js +149 -0
- package/src/plan/plan.js +116 -46
- package/src/plan/types.d.ts +44 -47
- package/src/types.d.ts +19 -0
- package/src/validationErrors.js +10 -5
- package/src/execute/columns.js +0 -102
package/src/parse/parse.js
CHANGED
|
@@ -3,62 +3,12 @@ import { parseJoins } from './joins.js'
|
|
|
3
3
|
import { consume, current, expect, expectIdentifier, match, parseError, peekToken } from './state.js'
|
|
4
4
|
import { tokenizeSql } from './tokenize.js'
|
|
5
5
|
import { duplicateCTEError } from '../parseErrors.js'
|
|
6
|
-
import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE, expectNoAggregate,
|
|
6
|
+
import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE, expectNoAggregate, findAggregate } from '../validation.js'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* @import { CTEDefinition, ExprNode, FromSubquery, FromTable, OrderByItem, ParseSqlOptions, ParserState, SelectStatement, SelectColumn, WithClause } from '../types.js'
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* Parses a WITH clause containing one or more CTEs
|
|
14
|
-
* @param {ParserState} state
|
|
15
|
-
* @returns {WithClause}
|
|
16
|
-
*/
|
|
17
|
-
function parseWithClause(state) {
|
|
18
|
-
/** @type {CTEDefinition[]} */
|
|
19
|
-
const ctes = []
|
|
20
|
-
/** @type {Set<string>} */
|
|
21
|
-
const seenNames = new Set()
|
|
22
|
-
|
|
23
|
-
while (true) {
|
|
24
|
-
// Parse CTE name
|
|
25
|
-
const nameTok = expectIdentifier(state)
|
|
26
|
-
const name = nameTok.value
|
|
27
|
-
const nameLower = name.toLowerCase()
|
|
28
|
-
|
|
29
|
-
// Check for duplicate CTE names
|
|
30
|
-
if (seenNames.has(nameLower)) {
|
|
31
|
-
throw duplicateCTEError({
|
|
32
|
-
cteName: name,
|
|
33
|
-
positionStart: nameTok.positionStart,
|
|
34
|
-
positionEnd: nameTok.positionEnd,
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
seenNames.add(nameLower)
|
|
38
|
-
|
|
39
|
-
// Expect AS keyword
|
|
40
|
-
expect(state, 'keyword', 'AS')
|
|
41
|
-
|
|
42
|
-
// Expect opening parenthesis
|
|
43
|
-
expect(state, 'paren', '(')
|
|
44
|
-
|
|
45
|
-
// Parse the CTE's SELECT statement
|
|
46
|
-
const query = parseSelectInternal(state)
|
|
47
|
-
|
|
48
|
-
// Expect closing parenthesis
|
|
49
|
-
expect(state, 'paren', ')')
|
|
50
|
-
|
|
51
|
-
ctes.push({ name, query })
|
|
52
|
-
|
|
53
|
-
// Check for comma (more CTEs) or end of WITH clause
|
|
54
|
-
if (!match(state, 'comma')) {
|
|
55
|
-
break
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return { ctes }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
12
|
/**
|
|
63
13
|
* @param {ParseSqlOptions} options
|
|
64
14
|
* @returns {SelectStatement}
|
|
@@ -66,7 +16,7 @@ function parseWithClause(state) {
|
|
|
66
16
|
export function parseSql({ query, functions }) {
|
|
67
17
|
const tokens = tokenizeSql(query)
|
|
68
18
|
/** @type {ParserState} */
|
|
69
|
-
const state = { tokens, pos: 0, functions }
|
|
19
|
+
const state = { tokens, pos: 0, lastPos: 0, functions }
|
|
70
20
|
|
|
71
21
|
// Check for WITH clause
|
|
72
22
|
/** @type {WithClause | undefined} */
|
|
@@ -130,29 +80,59 @@ function parseSelectList(state) {
|
|
|
130
80
|
return cols
|
|
131
81
|
}
|
|
132
82
|
|
|
133
|
-
// Keywords that can start a valid expression in SELECT
|
|
134
|
-
const EXPRESSION_START_KEYWORDS = new Set([
|
|
135
|
-
'CASE', 'TRUE', 'FALSE', 'NULL', 'EXISTS', 'NOT', 'INTERVAL',
|
|
136
|
-
])
|
|
137
|
-
|
|
138
83
|
/**
|
|
84
|
+
* Parses a WITH clause containing one or more CTEs
|
|
85
|
+
*
|
|
139
86
|
* @param {ParserState} state
|
|
140
|
-
* @returns {
|
|
87
|
+
* @returns {WithClause}
|
|
141
88
|
*/
|
|
142
|
-
function
|
|
143
|
-
|
|
89
|
+
function parseWithClause(state) {
|
|
90
|
+
/** @type {CTEDefinition[]} */
|
|
91
|
+
const ctes = []
|
|
92
|
+
/** @type {Set<string>} */
|
|
93
|
+
const seenNames = new Set()
|
|
94
|
+
|
|
95
|
+
while (true) {
|
|
96
|
+
// Parse CTE name
|
|
97
|
+
const nameTok = expectIdentifier(state)
|
|
98
|
+
const name = nameTok.value
|
|
99
|
+
const nameLower = name.toLowerCase()
|
|
100
|
+
|
|
101
|
+
// Check for duplicate CTE names
|
|
102
|
+
if (seenNames.has(nameLower)) {
|
|
103
|
+
throw duplicateCTEError({
|
|
104
|
+
cteName: name,
|
|
105
|
+
positionStart: nameTok.positionStart,
|
|
106
|
+
positionEnd: nameTok.positionEnd,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
seenNames.add(nameLower)
|
|
110
|
+
|
|
111
|
+
// Expect AS statement
|
|
112
|
+
expect(state, 'keyword', 'AS')
|
|
113
|
+
expect(state, 'paren', '(')
|
|
114
|
+
|
|
115
|
+
// Parse the CTE's SELECT statement
|
|
116
|
+
const query = parseSelectInternal(state)
|
|
117
|
+
|
|
118
|
+
expect(state, 'paren', ')')
|
|
144
119
|
|
|
145
|
-
|
|
146
|
-
const isKeywordFunction = tok.type === 'keyword' &&
|
|
147
|
-
peekToken(state, 1).type === 'paren' &&
|
|
148
|
-
peekToken(state, 1).value === '(' &&
|
|
149
|
-
isKnownFunction(tok.value, state.functions)
|
|
120
|
+
ctes.push({ name, query })
|
|
150
121
|
|
|
151
|
-
|
|
152
|
-
|
|
122
|
+
// Check for comma (more CTEs) or end of WITH clause
|
|
123
|
+
if (!match(state, 'comma')) {
|
|
124
|
+
break
|
|
125
|
+
}
|
|
153
126
|
}
|
|
154
127
|
|
|
155
|
-
|
|
128
|
+
return { ctes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {ParserState} state
|
|
133
|
+
* @returns {SelectColumn}
|
|
134
|
+
*/
|
|
135
|
+
function parseSelectItem(state) {
|
|
156
136
|
const expr = parseExpression(state)
|
|
157
137
|
const alias = parseAs(state)
|
|
158
138
|
return { kind: 'derived', expr, alias }
|
|
@@ -283,10 +263,17 @@ export function parseSelectInternal(state) {
|
|
|
283
263
|
having = parseExpression(state)
|
|
284
264
|
}
|
|
285
265
|
|
|
266
|
+
const hasAggregate = groupBy.length > 0 || columns.some(col =>
|
|
267
|
+
col.kind === 'derived' && findAggregate(col.expr)
|
|
268
|
+
)
|
|
269
|
+
|
|
286
270
|
if (match(state, 'keyword', 'ORDER')) {
|
|
287
271
|
expect(state, 'keyword', 'BY')
|
|
288
272
|
while (true) {
|
|
289
273
|
const expr = parseExpression(state)
|
|
274
|
+
if (!hasAggregate) {
|
|
275
|
+
expectNoAggregate(expr, 'ORDER BY')
|
|
276
|
+
}
|
|
290
277
|
/** @type {'ASC' | 'DESC'} */
|
|
291
278
|
let direction = 'ASC'
|
|
292
279
|
if (match(state, 'keyword', 'ASC')) {
|
package/src/parse/state.js
CHANGED
|
@@ -39,15 +39,6 @@ export function consume(state) {
|
|
|
39
39
|
return tok
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* Gets the position after the last consumed token.
|
|
44
|
-
* @param {ParserState} state
|
|
45
|
-
* @returns {number}
|
|
46
|
-
*/
|
|
47
|
-
export function lastPosition(state) {
|
|
48
|
-
return state.lastPos ?? 0
|
|
49
|
-
}
|
|
50
|
-
|
|
51
42
|
/**
|
|
52
43
|
* @param {ParserState} state
|
|
53
44
|
* @param {TokenType} type
|
package/src/parse/tokenize.js
CHANGED
|
@@ -229,7 +229,7 @@ export function tokenizeSql(sql) {
|
|
|
229
229
|
let text = ''
|
|
230
230
|
while (i <= length) {
|
|
231
231
|
if (i === length) {
|
|
232
|
-
throw unterminatedError('string', pos, length)
|
|
232
|
+
throw unterminatedError({ type: 'string', positionStart: pos, positionEnd: length })
|
|
233
233
|
}
|
|
234
234
|
const c = nextChar()
|
|
235
235
|
if (c === quote) {
|
|
@@ -258,7 +258,7 @@ export function tokenizeSql(sql) {
|
|
|
258
258
|
let text = ''
|
|
259
259
|
while (i <= length) {
|
|
260
260
|
if (i === length) {
|
|
261
|
-
throw unterminatedError('identifier', pos, length)
|
|
261
|
+
throw unterminatedError({ type: 'identifier', positionStart: pos, positionEnd: length })
|
|
262
262
|
}
|
|
263
263
|
const c = nextChar()
|
|
264
264
|
if (c === quote) {
|
package/src/parse/types.d.ts
CHANGED
package/src/parseErrors.js
CHANGED
|
@@ -41,12 +41,13 @@ export function syntaxError({ expected, received, positionStart, positionEnd, af
|
|
|
41
41
|
/**
|
|
42
42
|
* Error for unterminated literals (strings, identifiers).
|
|
43
43
|
*
|
|
44
|
-
* @param {
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {number}
|
|
44
|
+
* @param {Object} options
|
|
45
|
+
* @param {'string' | 'identifier'} options.type - Type of unterminated literal
|
|
46
|
+
* @param {number} options.positionStart - Starting position
|
|
47
|
+
* @param {number} options.positionEnd - End position
|
|
47
48
|
* @returns {ParseError}
|
|
48
49
|
*/
|
|
49
|
-
export function unterminatedError(type, positionStart, positionEnd) {
|
|
50
|
+
export function unterminatedError({ type, positionStart, positionEnd }) {
|
|
50
51
|
const name = type === 'string' ? 'string literal' : 'identifier'
|
|
51
52
|
return new ParseError({ message: `Unterminated ${name} starting at position ${positionStart}`, positionStart, positionEnd })
|
|
52
53
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ExprNode, SelectStatement } from '../types.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts per-table column names needed from a SELECT statement with joins.
|
|
7
|
+
* Returns a Map from table alias to column names, or undefined if all columns needed.
|
|
8
|
+
*
|
|
9
|
+
* @param {SelectStatement} select
|
|
10
|
+
* @returns {Map<string, string[] | undefined>}
|
|
11
|
+
*/
|
|
12
|
+
export function extractColumns(select) {
|
|
13
|
+
// Build alias list from FROM + JOINs
|
|
14
|
+
const fromAlias = select.from.kind === 'table'
|
|
15
|
+
? select.from.alias ?? select.from.table
|
|
16
|
+
: select.from.alias
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
const aliases = [fromAlias]
|
|
19
|
+
for (const join of select.joins) {
|
|
20
|
+
aliases.push(join.alias ?? join.table)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// If any unqualified SELECT * exists, all tables need all columns
|
|
24
|
+
if (select.columns.some(col => col.kind === 'star' && !col.table)) {
|
|
25
|
+
/** @type {Map<string, string[] | undefined>} */
|
|
26
|
+
const result = new Map()
|
|
27
|
+
for (const alias of aliases) {
|
|
28
|
+
result.set(alias, undefined)
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Track which tables need all columns (SELECT table.*)
|
|
34
|
+
/** @type {Set<string>} */
|
|
35
|
+
const allColumnsNeeded = new Set()
|
|
36
|
+
for (const col of select.columns) {
|
|
37
|
+
if (col.kind === 'star' && col.table) {
|
|
38
|
+
allColumnsNeeded.add(col.table)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Collect all identifiers from all clauses
|
|
43
|
+
/** @type {Set<string>} */
|
|
44
|
+
const identifiers = new Set()
|
|
45
|
+
for (const col of select.columns) {
|
|
46
|
+
if (col.kind === 'derived') {
|
|
47
|
+
collectColumnsFromExpr(col.expr, identifiers)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
collectColumnsFromExpr(select.where, identifiers)
|
|
51
|
+
for (const item of select.orderBy) {
|
|
52
|
+
collectColumnsFromExpr(item.expr, identifiers)
|
|
53
|
+
}
|
|
54
|
+
for (const expr of select.groupBy) {
|
|
55
|
+
collectColumnsFromExpr(expr, identifiers)
|
|
56
|
+
}
|
|
57
|
+
collectColumnsFromExpr(select.having, identifiers)
|
|
58
|
+
for (const join of select.joins) {
|
|
59
|
+
collectColumnsFromExpr(join.on, identifiers)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Initialize per-table sets (skip tables needing all columns)
|
|
63
|
+
/** @type {Map<string, Set<string>>} */
|
|
64
|
+
const perTable = new Map()
|
|
65
|
+
for (const alias of aliases) {
|
|
66
|
+
if (!allColumnsNeeded.has(alias)) {
|
|
67
|
+
perTable.set(alias, new Set())
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Partition identifiers by table prefix
|
|
72
|
+
for (const name of identifiers) {
|
|
73
|
+
const dotIndex = name.indexOf('.')
|
|
74
|
+
if (dotIndex >= 0) {
|
|
75
|
+
// Qualified: add to matching table only
|
|
76
|
+
const tablePrefix = name.substring(0, dotIndex)
|
|
77
|
+
const columnName = name.substring(dotIndex + 1)
|
|
78
|
+
const set = perTable.get(tablePrefix)
|
|
79
|
+
if (set) {
|
|
80
|
+
set.add(columnName)
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Unqualified: add to all tables (ambiguous)
|
|
84
|
+
for (const [, set] of perTable) {
|
|
85
|
+
set.add(name)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build result map: convert Sets to arrays, undefined for all-columns tables
|
|
91
|
+
/** @type {Map<string, string[] | undefined>} */
|
|
92
|
+
const result = new Map()
|
|
93
|
+
for (const alias of aliases) {
|
|
94
|
+
if (allColumnsNeeded.has(alias)) {
|
|
95
|
+
result.set(alias, undefined)
|
|
96
|
+
} else {
|
|
97
|
+
const set = perTable.get(alias)
|
|
98
|
+
result.set(alias, set ? [...set] : undefined)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Recursively collects column names (identifiers) from an expression
|
|
106
|
+
*
|
|
107
|
+
* @param {ExprNode | undefined} expr
|
|
108
|
+
* @param {Set<string>} columns
|
|
109
|
+
*/
|
|
110
|
+
function collectColumnsFromExpr(expr, columns) {
|
|
111
|
+
if (!expr) return
|
|
112
|
+
if (expr.type === 'identifier' && expr.name !== '*') {
|
|
113
|
+
columns.add(expr.name)
|
|
114
|
+
} else if (expr.type === 'literal') {
|
|
115
|
+
// No columns
|
|
116
|
+
} else if (expr.type === 'binary') {
|
|
117
|
+
collectColumnsFromExpr(expr.left, columns)
|
|
118
|
+
collectColumnsFromExpr(expr.right, columns)
|
|
119
|
+
} else if (expr.type === 'unary') {
|
|
120
|
+
collectColumnsFromExpr(expr.argument, columns)
|
|
121
|
+
} else if (expr.type === 'function') {
|
|
122
|
+
for (const arg of expr.args) {
|
|
123
|
+
collectColumnsFromExpr(arg, columns)
|
|
124
|
+
}
|
|
125
|
+
} else if (expr.type === 'cast') {
|
|
126
|
+
collectColumnsFromExpr(expr.expr, columns)
|
|
127
|
+
} else if (expr.type === 'in valuelist') {
|
|
128
|
+
collectColumnsFromExpr(expr.expr, columns)
|
|
129
|
+
for (const val of expr.values) {
|
|
130
|
+
collectColumnsFromExpr(val, columns)
|
|
131
|
+
}
|
|
132
|
+
} else if (expr.type === 'in') {
|
|
133
|
+
collectColumnsFromExpr(expr.expr, columns)
|
|
134
|
+
// Subquery columns are from a different scope, don't collect
|
|
135
|
+
} else if (expr.type === 'exists' || expr.type === 'not exists') {
|
|
136
|
+
// Subquery columns are from a different scope, don't collect
|
|
137
|
+
} else if (expr.type === 'case') {
|
|
138
|
+
if (expr.caseExpr) {
|
|
139
|
+
collectColumnsFromExpr(expr.caseExpr, columns)
|
|
140
|
+
}
|
|
141
|
+
for (const when of expr.whenClauses) {
|
|
142
|
+
collectColumnsFromExpr(when.condition, columns)
|
|
143
|
+
collectColumnsFromExpr(when.result, columns)
|
|
144
|
+
}
|
|
145
|
+
if (expr.elseResult) {
|
|
146
|
+
collectColumnsFromExpr(expr.elseResult, columns)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/plan/plan.js
CHANGED
|
@@ -1,40 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseSql } from '../parse/parse.js'
|
|
2
2
|
import { findAggregate } from '../validation.js'
|
|
3
|
+
import { extractColumns } from './columns.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* @import { ExprNode, JoinClause, ScanOptions, SelectStatement } from '../types.js'
|
|
6
|
-
* @import { QueryPlan
|
|
6
|
+
* @import { ExprNode, JoinClause, PlanSqlOptions, ScanOptions, SelectStatement } from '../types.js'
|
|
7
|
+
* @import { QueryPlan } from './types.d.ts'
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Builds a query plan from a SELECT statement AST.
|
|
11
12
|
* Resolves CTEs at plan time so no planning occurs during execution.
|
|
12
13
|
*
|
|
13
|
-
* @param {
|
|
14
|
+
* @param {PlanSqlOptions} options
|
|
14
15
|
* @returns {QueryPlan} the root of the query plan tree
|
|
15
16
|
*/
|
|
16
|
-
export function
|
|
17
|
+
export function planSql({ query, functions }) {
|
|
18
|
+
const select = typeof query === 'string' ? parseSql({ query, functions }) : query
|
|
19
|
+
|
|
17
20
|
// Build CTE plans in order (each CTE can reference preceding CTEs)
|
|
18
21
|
/** @type {Map<string, QueryPlan>} */
|
|
19
22
|
const ctePlans = new Map()
|
|
20
23
|
if (select.with) {
|
|
21
24
|
for (const cte of select.with.ctes) {
|
|
22
|
-
const ctePlan =
|
|
25
|
+
const ctePlan = planSelect({ select: cte.query, ctePlans })
|
|
23
26
|
ctePlans.set(cte.name.toLowerCase(), ctePlan)
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
return
|
|
30
|
+
return planSelect({ select, ctePlans })
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
* Builds a plan for a SELECT statement with
|
|
34
|
+
* Builds a plan for a SELECT statement with CTEs pre-resolved.
|
|
32
35
|
*
|
|
33
|
-
* @param {
|
|
34
|
-
* @param {
|
|
35
|
-
* @
|
|
36
|
+
* @param {object} options
|
|
37
|
+
* @param {SelectStatement} options.select
|
|
38
|
+
* @param {Map<string, QueryPlan>} options.ctePlans
|
|
39
|
+
* @returns {QueryPlan}
|
|
36
40
|
*/
|
|
37
|
-
function
|
|
41
|
+
function planSelect({ select, ctePlans }) {
|
|
38
42
|
// Check for aggregation
|
|
39
43
|
const hasAggregate = select.columns.some(col =>
|
|
40
44
|
col.kind === 'derived' && findAggregate(col.expr)
|
|
@@ -42,18 +46,24 @@ function buildSelectPlan(select, ctePlans) {
|
|
|
42
46
|
const useGrouping = hasAggregate || select.groupBy.length > 0
|
|
43
47
|
const needsBuffering = useGrouping || select.orderBy.length > 0
|
|
44
48
|
|
|
45
|
-
//
|
|
49
|
+
// Source alias for FROM clause
|
|
50
|
+
const sourceAlias = select.from.kind === 'table'
|
|
51
|
+
? select.from.alias ?? select.from.table
|
|
52
|
+
: select.from.alias
|
|
53
|
+
|
|
54
|
+
// Determine per-table column hints for pushdown
|
|
46
55
|
/** @type {ScanOptions} */
|
|
47
|
-
const hints = {
|
|
56
|
+
const hints = {}
|
|
57
|
+
const perTableColumns = extractColumns(select)
|
|
58
|
+
hints.columns = perTableColumns.get(sourceAlias)
|
|
59
|
+
|
|
60
|
+
// Start with the data source (FROM clause)
|
|
48
61
|
/** @type {QueryPlan} */
|
|
49
|
-
let plan =
|
|
62
|
+
let plan = planFrom({ select, ctePlans, hints })
|
|
50
63
|
|
|
51
64
|
// Add JOINs
|
|
52
65
|
if (select.joins.length) {
|
|
53
|
-
|
|
54
|
-
? select.from.alias ?? select.from.table
|
|
55
|
-
: select.from.alias
|
|
56
|
-
plan = buildJoinPlan(plan, select.joins, sourceAlias, ctePlans)
|
|
66
|
+
plan = planJoin({ left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, perTableColumns })
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
// Delegate WHERE and LIMIT/OFFSET to scan when plan is a direct table scan
|
|
@@ -96,7 +106,7 @@ function buildSelectPlan(select, ctePlans) {
|
|
|
96
106
|
// Non-aggregation path
|
|
97
107
|
|
|
98
108
|
// ORDER BY (before projection so it can access all columns)
|
|
99
|
-
//
|
|
109
|
+
// Resolve SELECT aliases in ORDER BY expressions at plan time
|
|
100
110
|
if (select.orderBy.length) {
|
|
101
111
|
/** @type {Map<string, ExprNode>} */
|
|
102
112
|
const aliases = new Map()
|
|
@@ -105,7 +115,10 @@ function buildSelectPlan(select, ctePlans) {
|
|
|
105
115
|
aliases.set(col.alias, col.expr)
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
|
-
|
|
118
|
+
const orderBy = aliases.size > 0
|
|
119
|
+
? select.orderBy.map(term => ({ ...term, expr: resolveAliases(term.expr, aliases) }))
|
|
120
|
+
: select.orderBy
|
|
121
|
+
plan = { type: 'Sort', orderBy, child: plan }
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
// DISTINCT needs to come after projection but before LIMIT
|
|
@@ -126,39 +139,37 @@ function buildSelectPlan(select, ctePlans) {
|
|
|
126
139
|
}
|
|
127
140
|
|
|
128
141
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* @param {
|
|
132
|
-
* @param {
|
|
133
|
-
* @param {ScanOptions} hints - scan options to pass to data source
|
|
142
|
+
* @param {object} options
|
|
143
|
+
* @param {SelectStatement} options.select
|
|
144
|
+
* @param {Map<string, QueryPlan>} options.ctePlans
|
|
145
|
+
* @param {ScanOptions} options.hints
|
|
134
146
|
* @returns {QueryPlan}
|
|
135
147
|
*/
|
|
136
|
-
function
|
|
148
|
+
function planFrom({ select, ctePlans, hints }) {
|
|
137
149
|
if (select.from.kind === 'table') {
|
|
138
150
|
const ctePlan = ctePlans.get(select.from.table.toLowerCase())
|
|
139
151
|
if (ctePlan) {
|
|
140
152
|
return ctePlan
|
|
141
153
|
}
|
|
142
|
-
return {
|
|
143
|
-
type: 'Scan',
|
|
144
|
-
table: select.from.table,
|
|
145
|
-
hints,
|
|
146
|
-
}
|
|
154
|
+
return { type: 'Scan', table: select.from.table, hints }
|
|
147
155
|
} else {
|
|
148
|
-
|
|
156
|
+
if (select.from.query.with) {
|
|
157
|
+
throw new Error('WITH clause is not supported inside subqueries')
|
|
158
|
+
}
|
|
159
|
+
return planSelect({ select: select.from.query, ctePlans })
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* @param {
|
|
156
|
-
* @param {
|
|
157
|
-
* @param {string}
|
|
158
|
-
* @param {Map<string,
|
|
164
|
+
* @param {object} options
|
|
165
|
+
* @param {QueryPlan} options.left - the left side of the join (FROM or previous joins)
|
|
166
|
+
* @param {JoinClause[]} options.joins - array of join clauses
|
|
167
|
+
* @param {string} options.leftTable - name/alias of the left table
|
|
168
|
+
* @param {Map<string, QueryPlan>} options.ctePlans
|
|
169
|
+
* @param {Map<string, string[] | undefined>} options.perTableColumns
|
|
159
170
|
* @returns {QueryPlan}
|
|
160
171
|
*/
|
|
161
|
-
function
|
|
172
|
+
function planJoin({ left, joins, leftTable, ctePlans, perTableColumns }) {
|
|
162
173
|
let plan = left
|
|
163
174
|
let currentLeftTable = leftTable
|
|
164
175
|
|
|
@@ -166,13 +177,18 @@ function buildJoinPlan(left, joins, leftTable, ctePlans) {
|
|
|
166
177
|
const rightTable = join.alias ?? join.table
|
|
167
178
|
|
|
168
179
|
const ctePlan = ctePlans.get(join.table.toLowerCase())
|
|
180
|
+
/** @type {ScanOptions} */
|
|
181
|
+
const rightHints = {}
|
|
182
|
+
if (!ctePlan) {
|
|
183
|
+
rightHints.columns = perTableColumns.get(rightTable)
|
|
184
|
+
}
|
|
169
185
|
/** @type {QueryPlan} */
|
|
170
|
-
const rightScan = ctePlan ?? { type: 'Scan', table: join.table
|
|
186
|
+
const rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
|
|
171
187
|
|
|
172
188
|
if (join.joinType === 'POSITIONAL') {
|
|
173
189
|
plan = { type: 'PositionalJoin', leftAlias: currentLeftTable, rightAlias: rightTable, left: plan, right: rightScan }
|
|
174
190
|
} else {
|
|
175
|
-
const keys = join.on && extractSimpleJoinKeys(join.on, currentLeftTable, rightTable)
|
|
191
|
+
const keys = join.on && extractSimpleJoinKeys({ condition: join.on, leftTable: currentLeftTable, rightTable })
|
|
176
192
|
if (keys) {
|
|
177
193
|
plan = {
|
|
178
194
|
type: 'HashJoin',
|
|
@@ -204,16 +220,70 @@ function buildJoinPlan(left, joins, leftTable, ctePlans) {
|
|
|
204
220
|
return plan
|
|
205
221
|
}
|
|
206
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Recursively replaces identifier nodes that match SELECT aliases
|
|
225
|
+
* with their aliased expressions.
|
|
226
|
+
*
|
|
227
|
+
* @param {ExprNode} node
|
|
228
|
+
* @param {Map<string, ExprNode>} aliases
|
|
229
|
+
* @returns {ExprNode}
|
|
230
|
+
*/
|
|
231
|
+
function resolveAliases(node, aliases) {
|
|
232
|
+
if (node.type === 'identifier') {
|
|
233
|
+
const resolved = aliases.get(node.name)
|
|
234
|
+
if (resolved) return resolved
|
|
235
|
+
return node
|
|
236
|
+
}
|
|
237
|
+
if (node.type === 'unary') {
|
|
238
|
+
const argument = resolveAliases(node.argument, aliases)
|
|
239
|
+
return argument === node.argument ? node : { ...node, argument }
|
|
240
|
+
}
|
|
241
|
+
if (node.type === 'binary') {
|
|
242
|
+
const left = resolveAliases(node.left, aliases)
|
|
243
|
+
const right = resolveAliases(node.right, aliases)
|
|
244
|
+
return left === node.left && right === node.right ? node : { ...node, left, right }
|
|
245
|
+
}
|
|
246
|
+
if (node.type === 'function') {
|
|
247
|
+
const args = node.args.map(arg => resolveAliases(arg, aliases))
|
|
248
|
+
const changed = args.some((arg, i) => arg !== node.args[i])
|
|
249
|
+
return changed ? { ...node, args } : node
|
|
250
|
+
}
|
|
251
|
+
if (node.type === 'cast') {
|
|
252
|
+
const expr = resolveAliases(node.expr, aliases)
|
|
253
|
+
return expr === node.expr ? node : { ...node, expr }
|
|
254
|
+
}
|
|
255
|
+
if (node.type === 'in valuelist') {
|
|
256
|
+
const expr = resolveAliases(node.expr, aliases)
|
|
257
|
+
const values = node.values.map(v => resolveAliases(v, aliases))
|
|
258
|
+
const changed = expr !== node.expr || values.some((v, i) => v !== node.values[i])
|
|
259
|
+
return changed ? { ...node, expr, values } : node
|
|
260
|
+
}
|
|
261
|
+
if (node.type === 'case') {
|
|
262
|
+
const caseExpr = node.caseExpr ? resolveAliases(node.caseExpr, aliases) : node.caseExpr
|
|
263
|
+
const whenClauses = node.whenClauses.map(w => {
|
|
264
|
+
const condition = resolveAliases(w.condition, aliases)
|
|
265
|
+
const result = resolveAliases(w.result, aliases)
|
|
266
|
+
return condition === w.condition && result === w.result ? w : { ...w, condition, result }
|
|
267
|
+
})
|
|
268
|
+
const elseResult = node.elseResult ? resolveAliases(node.elseResult, aliases) : node.elseResult
|
|
269
|
+
const changed = caseExpr !== node.caseExpr || elseResult !== node.elseResult || whenClauses.some((w, i) => w !== node.whenClauses[i])
|
|
270
|
+
return changed ? { ...node, caseExpr, whenClauses, elseResult } : node
|
|
271
|
+
}
|
|
272
|
+
// literal, interval, subquery, in, exists: no identifiers to resolve
|
|
273
|
+
return node
|
|
274
|
+
}
|
|
275
|
+
|
|
207
276
|
/**
|
|
208
277
|
* Extracts left and right key expressions from a simple equality join condition.
|
|
209
278
|
* Returns undefined if the condition is not a simple equality between identifiers.
|
|
210
279
|
*
|
|
211
|
-
* @param {
|
|
212
|
-
* @param {
|
|
213
|
-
* @param {string}
|
|
280
|
+
* @param {object} options
|
|
281
|
+
* @param {ExprNode} options.condition
|
|
282
|
+
* @param {string} options.leftTable
|
|
283
|
+
* @param {string} options.rightTable
|
|
214
284
|
* @returns {{ leftKey: ExprNode, rightKey: ExprNode } | undefined}
|
|
215
285
|
*/
|
|
216
|
-
function extractSimpleJoinKeys(condition, leftTable, rightTable) {
|
|
286
|
+
function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
|
|
217
287
|
if (condition.type !== 'binary' || condition.op !== '=') {
|
|
218
288
|
return undefined
|
|
219
289
|
}
|