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.
@@ -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, isKnownFunction } from '../validation.js'
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 {SelectColumn}
87
+ * @returns {WithClause}
141
88
  */
142
- function parseSelectItem(state) {
143
- const tok = current(state)
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
- // Check if keyword followed by ( is a known function (e.g., LEFT, RIGHT)
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
- if (tok.type === 'keyword' && !EXPRESSION_START_KEYWORDS.has(tok.value) && !isKeywordFunction || tok.type === 'eof') {
152
- throw parseError(state, 'column name or expression')
122
+ // Check for comma (more CTEs) or end of WITH clause
123
+ if (!match(state, 'comma')) {
124
+ break
125
+ }
153
126
  }
154
127
 
155
- // Delegate to expression parser (handles all expressions including aggregates)
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')) {
@@ -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
@@ -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) {
@@ -3,7 +3,7 @@ import { UserDefinedFunction } from '../types.js'
3
3
  export interface ParserState {
4
4
  tokens: Token[]
5
5
  pos: number
6
- lastPos?: number
6
+ lastPos: number
7
7
  functions?: Record<string, UserDefinedFunction>
8
8
  }
9
9
 
@@ -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 {'string' | 'identifier'} type - Type of unterminated literal
45
- * @param {number} positionStart - Starting position
46
- * @param {number} positionEnd - End position
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 { extractColumns } from '../execute/columns.js'
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, ScanNode } from './types.d.ts'
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 {SelectStatement} select - the SELECT statement AST
14
+ * @param {PlanSqlOptions} options
14
15
  * @returns {QueryPlan} the root of the query plan tree
15
16
  */
16
- export function queryPlan(select) {
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 = buildSelectPlan(cte.query, ctePlans)
25
+ const ctePlan = planSelect({ select: cte.query, ctePlans })
23
26
  ctePlans.set(cte.name.toLowerCase(), ctePlan)
24
27
  }
25
28
  }
26
29
 
27
- return buildSelectPlan(select, ctePlans)
30
+ return planSelect({ select, ctePlans })
28
31
  }
29
32
 
30
33
  /**
31
- * Builds a plan for a SELECT statement with CTE resolution.
34
+ * Builds a plan for a SELECT statement with CTEs pre-resolved.
32
35
  *
33
- * @param {SelectStatement} select - the SELECT statement AST
34
- * @param {Map<string, QueryPlan>} ctePlans
35
- * @returns {QueryPlan} the root of the query plan tree
36
+ * @param {object} options
37
+ * @param {SelectStatement} options.select
38
+ * @param {Map<string, QueryPlan>} options.ctePlans
39
+ * @returns {QueryPlan}
36
40
  */
37
- function buildSelectPlan(select, ctePlans) {
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
- // Start with the data source (FROM clause)
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 = { columns: extractColumns(select) }
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 = buildFromPlan(select, ctePlans, hints)
62
+ let plan = planFrom({ select, ctePlans, hints })
50
63
 
51
64
  // Add JOINs
52
65
  if (select.joins.length) {
53
- const sourceAlias = select.from.kind === 'table'
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
- // Pass aliases so ORDER BY can reference SELECT column aliases
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
- plan = { type: 'Sort', orderBy: select.orderBy, aliases: aliases.size > 0 ? aliases : undefined, child: plan }
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
- * Builds a plan for the FROM clause
130
- *
131
- * @param {SelectStatement} select
132
- * @param {Map<string, QueryPlan>} ctePlans
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 buildFromPlan(select, ctePlans, hints) {
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
- return queryPlan(select.from.query)
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
- * Builds join plan nodes for all joins
154
- *
155
- * @param {QueryPlan} left - the left side of the join (FROM or previous joins)
156
- * @param {JoinClause[]} joins - array of join clauses
157
- * @param {string} leftTable - name/alias of the left table
158
- * @param {Map<string, QueryPlan>} ctePlans
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 buildJoinPlan(left, joins, leftTable, ctePlans) {
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 } // TODO: pass hints
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 {ExprNode} condition
212
- * @param {string} leftTable
213
- * @param {string} rightTable
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
  }