squirreling 0.11.2 → 0.11.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
package/src/ast.d.ts CHANGED
@@ -74,6 +74,7 @@ export interface LiteralNode extends AstBase {
74
74
  export interface IdentifierNode extends AstBase {
75
75
  type: 'identifier'
76
76
  name: string
77
+ prefix?: string
77
78
  }
78
79
 
79
80
  export interface UnaryNode extends AstBase {
@@ -30,7 +30,7 @@ function projectAggregateColumns(selectColumns, group, context) {
30
30
  for (const key of firstRow.columns) {
31
31
  if (prefix && !key.startsWith(prefix)) continue
32
32
  const dotIndex = key.indexOf('.')
33
- const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
33
+ const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
34
34
  columns.push(outputKey)
35
35
  cells[outputKey] = firstRow.cells[key]
36
36
  }
@@ -244,7 +244,7 @@ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
244
244
  if (spec.funcName === 'COUNT' && spec.distinct) {
245
245
  const seen = new Set()
246
246
  for await (const chunk of values) {
247
- if (signal?.aborted) return null
247
+ if (signal?.aborted) return
248
248
  for (let i = 0; i < chunk.length; i++) {
249
249
  const v = chunk[i]
250
250
  if (v == null) continue
@@ -257,7 +257,7 @@ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
257
257
  if (spec.funcName === 'COUNT') {
258
258
  let count = 0
259
259
  for await (const chunk of values) {
260
- if (signal?.aborted) return null
260
+ if (signal?.aborted) return
261
261
  for (let i = 0; i < chunk.length; i++) {
262
262
  if (chunk[i] != null) count++
263
263
  }
@@ -274,7 +274,7 @@ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
274
274
  let max = null
275
275
 
276
276
  for await (const chunk of values) {
277
- if (signal?.aborted) return null
277
+ if (signal?.aborted) return
278
278
  for (let i = 0; i < chunk.length; i++) {
279
279
  const v = chunk[i]
280
280
  if (v == null) continue
@@ -98,6 +98,29 @@ async function* executeScan(plan, context) {
98
98
  const table = validateTable({ ...plan, tables })
99
99
  validateScan({ ...plan, tables })
100
100
 
101
+ // Fast path: single column scan without WHERE
102
+ if (table.scanColumn && plan.hints.columns?.length === 1 && !plan.hints.where) {
103
+ const column = plan.hints.columns[0]
104
+ const chunks = table.scanColumn({
105
+ column,
106
+ limit: plan.hints.limit,
107
+ offset: plan.hints.offset,
108
+ signal,
109
+ })
110
+ const columns = [column]
111
+ for await (const chunk of chunks) {
112
+ if (signal?.aborted) return
113
+ for (let i = 0; i < chunk.length; i++) {
114
+ const value = chunk[i]
115
+ yield {
116
+ columns,
117
+ cells: { [column]: () => Promise.resolve(value) },
118
+ }
119
+ }
120
+ }
121
+ return
122
+ }
123
+
101
124
  // do the scan
102
125
  const { rows, appliedWhere, appliedLimitOffset } = table.scan({ ...plan.hints, signal })
103
126
 
@@ -267,7 +290,7 @@ async function* executeProject(plan, context) {
267
290
  if (prefix && !key.startsWith(prefix)) continue
268
291
  // Strip table prefix for output column names
269
292
  const dotIndex = key.indexOf('.')
270
- const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
293
+ const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
271
294
  columns.push(outputKey)
272
295
  cells[outputKey] = row.cells[key]
273
296
  }
@@ -26,18 +26,18 @@ export async function* executeNestedLoopJoin(plan, context) {
26
26
  rightRows.push(row)
27
27
  }
28
28
 
29
- const rightPrefixedCols = rightRows.length ? prefixColumns(rightRows[0].columns, rightTable) : []
29
+ const rightCols = rightRows.length ? rightRows[0].columns : []
30
30
 
31
31
  /** @type {string[] | undefined} */
32
- let leftPrefixedCols = undefined
32
+ let leftCols = undefined
33
33
  /** @type {Set<AsyncRow> | undefined} */
34
34
  const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
35
35
 
36
36
  for await (const leftRow of executePlan({ plan: plan.left, context })) {
37
37
  if (context.signal?.aborted) break
38
38
 
39
- if (!leftPrefixedCols) {
40
- leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
39
+ if (!leftCols) {
40
+ leftCols = leftRow.columns
41
41
  }
42
42
 
43
43
  let hasMatch = false
@@ -58,7 +58,7 @@ export async function* executeNestedLoopJoin(plan, context) {
58
58
  }
59
59
 
60
60
  if (!hasMatch && (plan.joinType === 'LEFT' || plan.joinType === 'FULL')) {
61
- const nullRight = createNullRow(rightPrefixedCols)
61
+ const nullRight = createNullRow(rightCols)
62
62
  yield mergeRows(leftRow, nullRight, leftTable, rightTable)
63
63
  }
64
64
  }
@@ -67,7 +67,7 @@ export async function* executeNestedLoopJoin(plan, context) {
67
67
  if (matchedRightRows) {
68
68
  for (const rightRow of rightRows) {
69
69
  if (!matchedRightRows.has(rightRow)) {
70
- const nullLeft = createNullRow(leftPrefixedCols ?? [])
70
+ const nullLeft = createNullRow(leftCols ?? [])
71
71
  yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
72
72
  }
73
73
  }
@@ -104,13 +104,11 @@ export async function* executePositionalJoin(plan, context) {
104
104
  const maxLen = Math.max(leftRows.length, rightRows.length)
105
105
  const leftCols = leftRows[0]?.columns ?? []
106
106
  const rightCols = rightRows[0]?.columns ?? []
107
- const leftPrefixedCols = prefixColumns(leftCols, leftTable)
108
- const rightPrefixedCols = prefixColumns(rightCols, rightTable)
109
107
 
110
108
  for (let i = 0; i < maxLen; i++) {
111
109
  if (signal?.aborted) return
112
- const leftRow = leftRows[i] ?? createNullRow(leftPrefixedCols)
113
- const rightRow = rightRows[i] ?? createNullRow(rightPrefixedCols)
110
+ const leftRow = leftRows[i] ?? createNullRow(leftCols)
111
+ const rightRow = rightRows[i] ?? createNullRow(rightCols)
114
112
  yield mergeRows(leftRow, rightRow, leftTable, rightTable)
115
113
  }
116
114
  }
@@ -154,10 +152,9 @@ export async function* executeHashJoin(plan, context) {
154
152
 
155
153
  // Get column info for NULL row generation
156
154
  const rightCols = rightRows.length ? rightRows[0].columns : []
157
- const rightPrefixedCols = prefixColumns(rightCols, rightTable)
158
155
 
159
156
  /** @type {string[] | undefined} */
160
- let leftPrefixedCols
157
+ let leftCols
161
158
  /** @type {Set<AsyncRow> | undefined} */
162
159
  const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
163
160
 
@@ -165,8 +162,8 @@ export async function* executeHashJoin(plan, context) {
165
162
  for await (const leftRow of executePlan({ plan: plan.left, context })) {
166
163
  if (context.signal?.aborted) break
167
164
 
168
- if (!leftPrefixedCols) {
169
- leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
165
+ if (!leftCols) {
166
+ leftCols = leftRow.columns
170
167
  }
171
168
 
172
169
  const keyValue = await evaluateExpr({
@@ -183,7 +180,7 @@ export async function* executeHashJoin(plan, context) {
183
180
  yield mergeRows(leftRow, rightRow, leftTable, rightTable)
184
181
  }
185
182
  } else if (plan.joinType === 'LEFT' || plan.joinType === 'FULL') {
186
- const nullRight = createNullRow(rightPrefixedCols)
183
+ const nullRight = createNullRow(rightCols)
187
184
  yield mergeRows(leftRow, nullRight, leftTable, rightTable)
188
185
  }
189
186
  }
@@ -192,7 +189,7 @@ export async function* executeHashJoin(plan, context) {
192
189
  if (matchedRightRows) {
193
190
  for (const rightRow of rightRows) {
194
191
  if (!matchedRightRows.has(rightRow)) {
195
- const nullLeft = createNullRow(leftPrefixedCols ?? [])
192
+ const nullLeft = createNullRow(leftCols ?? [])
196
193
  yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
197
194
  }
198
195
  }
@@ -244,14 +241,3 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
244
241
 
245
242
  return { columns, cells }
246
243
  }
247
-
248
- /**
249
- * Prefixes column names with table alias, keeping already-prefixed columns as-is
250
- *
251
- * @param {string[]} cols
252
- * @param {string} table
253
- * @returns {string[]}
254
- */
255
- function prefixColumns(cols, table) {
256
- return cols.map(col => col.includes('.') ? col : `${table}.${col}`)
257
- }
@@ -10,11 +10,6 @@
10
10
  */
11
11
  export function derivedAlias(expr) {
12
12
  if (expr.type === 'identifier') {
13
- // For qualified names like 'users.name', use just the column part as alias
14
- const dotIndex = expr.name.indexOf('.')
15
- if (dotIndex >= 0) {
16
- return expr.name.substring(dotIndex + 1)
17
- }
18
13
  return expr.name
19
14
  }
20
15
  if (expr.type === 'literal') {
@@ -33,18 +33,21 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
33
33
  }
34
34
 
35
35
  if (node.type === 'identifier') {
36
- // Try exact match first (handles both qualified and unqualified names)
37
- if (node.name in row.cells) {
38
- return row.cells[node.name]()
39
- }
40
- const dotIndex = node.name.indexOf('.')
41
- if (dotIndex >= 0) {
42
- // For qualified names like 'users.id', try just the column part
43
- const colName = node.name.substring(dotIndex + 1)
44
- if (colName in row.cells) {
45
- return row.cells[colName]()
36
+ // Try qualified name first (e.g. 'users.id')
37
+ if (node.prefix) {
38
+ const qualified = node.prefix + '.' + node.name
39
+ if (qualified in row.cells) {
40
+ return row.cells[qualified]()
41
+ }
42
+ // Fall back to just the column part
43
+ if (node.name in row.cells) {
44
+ return row.cells[node.name]()
46
45
  }
47
46
  } else {
47
+ // Try exact match first
48
+ if (node.name in row.cells) {
49
+ return row.cells[node.name]()
50
+ }
48
51
  // For unqualified names, search for a matching prefixed column (e.g. 'id' to 'a.id')
49
52
  const suffix = '.' + node.name
50
53
  const match = row.columns.find(col => col.endsWith(suffix))
@@ -54,7 +57,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
54
57
  }
55
58
  // Unknown identifier
56
59
  throw new ColumnNotFoundError({
57
- missingColumn: node.name,
60
+ missingColumn: node.prefix ? node.prefix + '.' + node.name : node.name,
58
61
  availableColumns: row.columns,
59
62
  rowIndex,
60
63
  ...node,
@@ -169,19 +169,25 @@ function parseIntersectOperations(state) {
169
169
  */
170
170
  function parseSelect(state) {
171
171
  const { positionStart } = current(state)
172
- expect(state, 'keyword', 'SELECT')
173
-
174
- const distinct = match(state, 'keyword', 'DISTINCT')
175
-
176
- const columns = parseSelectList(state)
172
+ /** @type {SelectColumn[]} */
173
+ let columns
174
+ let distinct = false
177
175
 
178
- expect(state, 'keyword', 'FROM')
176
+ // Support duckdb-style shorthand "FROM table"
177
+ if (match(state, 'keyword', 'FROM')) {
178
+ columns = [{ type: 'star' }]
179
+ } else {
180
+ expect(state, 'keyword', 'SELECT')
181
+ distinct = match(state, 'keyword', 'DISTINCT')
182
+ columns = parseSelectList(state)
183
+ expect(state, 'keyword', 'FROM')
184
+ }
179
185
 
180
186
  // Check if it's a subquery or table name
181
187
  /** @type {FromTable | FromSubquery} */
182
188
  let from
183
- const tok = current(state)
184
- if (tok.type === 'paren' && tok.value === '(') {
189
+ const fromTok = current(state)
190
+ if (fromTok.type === 'paren' && fromTok.value === '(') {
185
191
  // Subquery: SELECT * FROM (SELECT ...) AS alias
186
192
  expect(state, 'paren', '(')
187
193
  const query = parseStatement(state)
@@ -191,7 +197,7 @@ function parseSelect(state) {
191
197
  type: 'subquery',
192
198
  query,
193
199
  alias,
194
- positionStart: tok.positionStart,
200
+ positionStart: fromTok.positionStart,
195
201
  positionEnd: state.lastPos,
196
202
  }
197
203
  } else {
@@ -200,9 +206,9 @@ function parseSelect(state) {
200
206
  const alias = parseTableAlias(state)
201
207
  from = {
202
208
  type: 'table',
203
- table: tok.value,
209
+ table: fromTok.value,
204
210
  alias,
205
- positionStart: tok.positionStart,
211
+ positionStart: fromTok.positionStart,
206
212
  positionEnd: state.lastPos,
207
213
  }
208
214
  }
@@ -112,15 +112,19 @@ export function parsePrimary(state) {
112
112
 
113
113
  // Table identifier
114
114
  let name = consume(state).value
115
+ /** @type {string | undefined} */
116
+ let prefix
115
117
 
116
118
  // table.column
117
119
  if (match(state, 'dot')) {
118
- name += '.' + expect(state, 'identifier').value
120
+ prefix = name
121
+ name = expect(state, 'identifier').value
119
122
  }
120
123
 
121
124
  return {
122
125
  type: 'identifier',
123
126
  name,
127
+ prefix,
124
128
  positionStart,
125
129
  positionEnd: state.lastPos,
126
130
  }
@@ -1,7 +1,7 @@
1
1
  import { derivedAlias } from '../expression/alias.js'
2
2
 
3
3
  /**
4
- * @import { AsyncDataSource, ExprNode, FromSubquery, FromTable, SelectStatement, Statement } from '../types.js'
4
+ * @import { AsyncDataSource, ExprNode, FromSubquery, FromTable, IdentifierNode, SelectStatement, Statement } from '../types.js'
5
5
  */
6
6
 
7
7
  /**
@@ -18,7 +18,7 @@ export function fromAlias(from) {
18
18
  *
19
19
  * @param {object} options
20
20
  * @param {SelectStatement} options.select
21
- * @param {string[]} [options.parentColumns] - columns needed by the parent query
21
+ * @param {IdentifierNode[]} [options.parentColumns] - columns needed by the parent query
22
22
  * @returns {Map<string, string[] | undefined>}
23
23
  */
24
24
  export function extractColumns({ select, parentColumns }) {
@@ -54,7 +54,8 @@ export function extractColumns({ select, parentColumns }) {
54
54
  // directly. For non-star queries, parent names may be aliases and are
55
55
  // handled below by filtering derived columns and collecting from expressions.
56
56
  const hasStar = select.columns.some(col => col.type === 'star' && !col.table)
57
- const identifiers = new Set(hasStar ? parentColumns : undefined)
57
+ /** @type {IdentifierNode[]} */
58
+ const identifiers = hasStar && parentColumns ? [...parentColumns] : []
58
59
 
59
60
  // Collect ORDER BY identifiers, excluding SELECT aliases (their underlying
60
61
  // columns are already collected from select.columns expressions above)
@@ -69,7 +70,7 @@ export function extractColumns({ select, parentColumns }) {
69
70
  // When parentColumns is set, skip columns the parent doesn't need
70
71
  if (parentColumns) {
71
72
  const outputName = col.alias ?? derivedAlias(col.expr)
72
- if (!parentColumns.includes(outputName)) continue
73
+ if (!parentColumns.some(id => id.name === outputName)) continue
73
74
  }
74
75
  // Exclude earlier SELECT aliases so they aren't treated as source columns
75
76
  collectColumnsFromExpr(col.expr, identifiers, selectAliases)
@@ -84,7 +85,7 @@ export function extractColumns({ select, parentColumns }) {
84
85
  collectColumnsFromExpr(item.expr, identifiers, selectAliases)
85
86
  }
86
87
  for (const expr of select.groupBy) {
87
- collectColumnsFromExpr(expr, identifiers)
88
+ collectColumnsFromExpr(expr, identifiers, selectAliases)
88
89
  }
89
90
  collectColumnsFromExpr(select.having, identifiers, selectAliases)
90
91
  for (const join of select.joins) {
@@ -92,14 +93,11 @@ export function extractColumns({ select, parentColumns }) {
92
93
  }
93
94
 
94
95
  // Partition identifiers by table prefix
95
- for (const name of identifiers) {
96
- const dotIndex = name.indexOf('.')
97
- if (dotIndex >= 0) {
96
+ for (const { prefix, name } of identifiers) {
97
+ if (prefix) {
98
98
  // Qualified: add to matching table only
99
- const tablePrefix = name.substring(0, dotIndex)
100
- const columnName = name.substring(dotIndex + 1)
101
- const set = perTable.get(tablePrefix)
102
- if (set) set.add(columnName)
99
+ const set = perTable.get(prefix)
100
+ if (set) set.add(name)
103
101
  } else if (aliases.length > 1) {
104
102
  // Unqualified in a JOIN: can't disambiguate, request all columns from all tables
105
103
  for (const alias of aliases) {
@@ -122,17 +120,17 @@ export function extractColumns({ select, parentColumns }) {
122
120
  }
123
121
 
124
122
  /**
125
- * Recursively collects column names (identifiers) from an expression
123
+ * Recursively collects identifier nodes from an expression
126
124
  *
127
125
  * @param {ExprNode} expr
128
- * @param {Set<string>} columns
126
+ * @param {IdentifierNode[]} columns
129
127
  * @param {Set<string>} [aliases] - aliases to exclude from columns
130
128
  */
131
129
  function collectColumnsFromExpr(expr, columns, aliases) {
132
130
  if (!expr) return
133
131
  if (expr.type === 'identifier') {
134
- if (!aliases?.has(expr.name)) {
135
- columns.add(expr.name)
132
+ if (expr.prefix || !aliases?.has(expr.name)) {
133
+ columns.push(expr)
136
134
  }
137
135
  } else if (expr.type === 'binary') {
138
136
  collectColumnsFromExpr(expr.left, columns, aliases)
package/src/plan/plan.js CHANGED
@@ -6,7 +6,7 @@ import { validateScan, validateTableRefs } from '../validation/tables.js'
6
6
  import { extractColumns, fromAlias, inferStatementColumns } from './columns.js'
7
7
 
8
8
  /**
9
- * @import { AsyncDataSource, ExprNode, DerivedColumn, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
9
+ * @import { AsyncDataSource, ExprNode, DerivedColumn, IdentifierNode, JoinClause, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
10
10
  * @import { QueryPlan } from './types.d.ts'
11
11
  */
12
12
 
@@ -31,7 +31,7 @@ export function planSql({ query, functions, tables }) {
31
31
  * @param {Map<string, QueryPlan>} [options.ctePlans]
32
32
  * @param {Map<string, string[]>} [options.cteColumns]
33
33
  * @param {Record<string, AsyncDataSource>} [options.tables]
34
- * @param {string[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
34
+ * @param {IdentifierNode[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
35
35
  * @returns {QueryPlan}
36
36
  */
37
37
  function planStatement({ stmt, ctePlans, cteColumns, tables, parentColumns }) {
@@ -99,7 +99,7 @@ function planSetOperation({ compound, ctePlans, cteColumns, tables }) {
99
99
  * @param {Map<string, QueryPlan>} [options.ctePlans]
100
100
  * @param {Map<string, string[]>} [options.cteColumns]
101
101
  * @param {Record<string, AsyncDataSource>} [options.tables]
102
- * @param {string[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
102
+ * @param {IdentifierNode[]} [options.parentColumns] - columns needed by the parent query (for subquery pushdown)
103
103
  * @returns {QueryPlan}
104
104
  */
105
105
  function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
@@ -113,15 +113,25 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
113
113
  // Source alias for FROM clause
114
114
  const sourceAlias = fromAlias(select.from)
115
115
 
116
- // Validate qualified references
116
+ // Validate qualified references and resolve aliases
117
117
  const scopeTables = Object.fromEntries([sourceAlias, ...select.joins.map(j => j.alias ?? j.table)].map(a => [a, true]))
118
- for (const col of select.columns) {
118
+ /** @type {Map<string, ExprNode>} */
119
+ const aliases = new Map()
120
+ const columns = select.columns.map(col => {
119
121
  if (col.type === 'derived') {
120
122
  validateTableRefs(col.expr, scopeTables)
121
- } else if (col.table && !(col.table in scopeTables)) {
123
+ const expr = resolveAliases(col.expr, aliases)
124
+ if (col.alias) {
125
+ aliases.set(col.alias, expr)
126
+ }
127
+ return { ...col, expr }
128
+ }
129
+ // Validate qualified references
130
+ if (col.table && !(col.table in scopeTables)) {
122
131
  throw new TableNotFoundError({ table: col.table, tables: scopeTables })
123
132
  }
124
- }
133
+ return col
134
+ })
125
135
 
126
136
  // Determine scan hints for direct table scans (WHERE and LIMIT/OFFSET are
127
137
  // included so they are only applied to fresh scans, not CTE/subquery plans)
@@ -158,11 +168,15 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
158
168
  // Aggregation path: GROUP BY or scalar aggregate
159
169
  // HAVING is integrated into aggregate nodes for access to group context
160
170
  if (select.groupBy.length) {
161
- plan = { type: 'HashAggregate', groupBy: select.groupBy, columns: select.columns, having: select.having, child: plan }
171
+ // Resolve SELECT aliases in GROUP BY expressions at plan time
172
+ const groupBy = aliases.size > 0
173
+ ? select.groupBy.map(expr => resolveAliases(expr, aliases))
174
+ : select.groupBy
175
+ plan = { type: 'HashAggregate', groupBy, columns, having: select.having, child: plan }
162
176
  } else if (!select.having && !select.where && plan.type === 'Scan' && isOwnScan && isAllCountStar(select.columns)) {
163
177
  plan = { type: 'Count', table: plan.table, columns: select.columns }
164
178
  } else {
165
- plan = { type: 'ScalarAggregate', columns: select.columns, having: select.having, child: plan }
179
+ plan = { type: 'ScalarAggregate', columns, having: select.having, child: plan }
166
180
  }
167
181
 
168
182
  // ORDER BY (after aggregation)
@@ -185,13 +199,6 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
185
199
  // ORDER BY (before projection so it can access all columns)
186
200
  // Resolve SELECT aliases in ORDER BY expressions at plan time
187
201
  if (select.orderBy.length) {
188
- /** @type {Map<string, ExprNode>} */
189
- const aliases = new Map()
190
- for (const col of select.columns) {
191
- if (col.type === 'derived' && col.alias) {
192
- aliases.set(col.alias, col.expr)
193
- }
194
- }
195
202
  const orderBy = aliases.size > 0
196
203
  ? select.orderBy.map(term => ({ ...term, expr: resolveAliases(term.expr, aliases) }))
197
204
  : select.orderBy
@@ -205,21 +212,11 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
205
212
  // Fast path for SELECT * without joins
206
213
  const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length
207
214
  if (!isPassthrough) {
208
- // Resolve earlier SELECT aliases in later column expressions
209
- /** @type {Map<string, ExprNode>} */
210
- const colAliases = new Map()
211
- let projectColumns = select.columns.map(col => {
212
- if (col.type !== 'derived') return col
213
- const expr = resolveAliases(col.expr, colAliases)
214
- if (col.alias) {
215
- colAliases.set(col.alias, expr)
216
- }
217
- return { ...col, expr }
218
- })
215
+ let projectColumns = columns
219
216
  // When parent only needs specific columns, drop unneeded projections
220
217
  if (parentColumns) {
221
218
  projectColumns = projectColumns.filter(col =>
222
- col.type === 'star' || parentColumns.includes(col.alias ?? derivedAlias(col.expr))
219
+ col.type === 'star' || parentColumns.some(id => id.name === (col.alias ?? derivedAlias(col.expr)))
223
220
  )
224
221
  }
225
222
  plan = { type: 'Project', columns: projectColumns, child: plan }
@@ -255,7 +252,13 @@ function planFrom({ select, ctePlans, cteColumns, hints, tables }) {
255
252
  validateScan({ ...select.from, hints, tables })
256
253
  return { type: 'Scan', table: select.from.table, hints }
257
254
  } else {
258
- const subPlan = planStatement({ stmt: select.from.query, ctePlans, cteColumns, tables, parentColumns: hints.columns })
255
+ const subPlan = planStatement({
256
+ stmt: select.from.query,
257
+ ctePlans,
258
+ cteColumns,
259
+ tables,
260
+ parentColumns: hints.columns?.map(name => ({ type: 'identifier', name, positionStart: 0, positionEnd: 0 })),
261
+ })
259
262
  // Validate that requested columns exist in subquery output
260
263
  const availableColumns = inferStatementColumns({ stmt: select.from.query, cteColumns, tables })
261
264
  if (hints.columns && availableColumns.length) {
@@ -345,7 +348,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
345
348
  function resolveAliases(node, aliases) {
346
349
  if (!node || !aliases.size) return node
347
350
  if (node.type === 'identifier') {
348
- return aliases.get(node.name) ?? node
351
+ return node.prefix ? node : aliases.get(node.name) ?? node
349
352
  }
350
353
  if (node.type === 'unary') {
351
354
  return { ...node, argument: resolveAliases(node.argument, aliases) }
@@ -397,8 +400,8 @@ function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
397
400
  if (left.type !== 'identifier' || right.type !== 'identifier') return
398
401
 
399
402
  // Check if keys are in swapped order (right table ref on left side)
400
- const leftRefsRight = left.name.startsWith(`${rightTable}.`)
401
- const rightRefsLeft = right.name.startsWith(`${leftTable}.`)
403
+ const leftRefsRight = left.prefix === rightTable
404
+ const rightRefsLeft = right.prefix === leftTable
402
405
 
403
406
  if (leftRefsRight && rightRefsLeft) {
404
407
  return { leftKey: right, rightKey: left }
package/src/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExprNode, SelectStatement, SqlPrimitive, Statement } from './ast.js'
1
+ import type { ExprNode, SqlPrimitive, Statement } from './ast.js'
2
2
 
3
3
  export * from './ast.js'
4
4
  export { ParserState, Token, TokenType } from './parse/types.js'
@@ -53,11 +53,9 @@ export function validateScan({ table, hints, tables, positionStart, positionEnd
53
53
  export function validateTableRefs(expr, tables) {
54
54
  if (!expr) return
55
55
  if (expr.type === 'identifier') {
56
- const dotIndex = expr.name.indexOf('.')
57
- if (dotIndex >= 0) {
58
- const table = expr.name.substring(0, dotIndex)
59
- if (!(table in tables)) {
60
- throw new TableNotFoundError({ table, tables, positionStart: expr.positionStart, positionEnd: expr.positionStart + dotIndex })
56
+ if (expr.prefix) {
57
+ if (!(expr.prefix in tables)) {
58
+ throw new TableNotFoundError({ table: expr.prefix, tables, positionStart: expr.positionStart, positionEnd: expr.positionStart + expr.prefix.length })
61
59
  }
62
60
  }
63
61
  return