squirreling 0.3.1 → 0.4.1

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.
@@ -1,53 +1,22 @@
1
- /**
2
- * @import { AggregateFunc, AsyncDataSource, ExprNode, AsyncRow, SqlPrimitive } from '../types.js'
3
- */
4
-
5
1
  import { isAggregateFunc } from '../validation.js'
6
2
  import { evaluateExpr } from './expression.js'
7
3
 
8
4
  /**
9
- * Creates a context for evaluating HAVING expressions
10
- *
11
- * @param {Record<string, any>} resultRow - the aggregated result row
12
- * @param {AsyncRow[]} group - the group of rows
13
- * @returns {AsyncRow} a context row for HAVING evaluation
5
+ * @import { AggregateFunc, AsyncDataSource, ExprNode, AsyncRow, SqlPrimitive } from '../types.js'
14
6
  */
15
- function createHavingContext(resultRow, group) {
16
- // Include the first row of the group (for GROUP BY columns)
17
- const firstRow = group[0]
18
- /** @type {Record<string, any>} */
19
- const context = {}
20
- if (firstRow) {
21
- const keys = firstRow.getKeys()
22
- for (const key of keys) {
23
- context[key] = firstRow.getCell(key)
24
- }
25
- }
26
- // Merge with result row (which has aggregates computed)
27
- Object.assign(context, resultRow)
28
-
29
- // Return a Row accessor wrapping the context
30
- return {
31
- getCell(name) {
32
- return context[name]
33
- },
34
- getKeys() {
35
- return Object.keys(context)
36
- },
37
- }
38
- }
39
7
 
40
8
  /**
41
9
  * Evaluates a HAVING expression with support for aggregate functions
42
10
  *
43
11
  * @param {ExprNode} expr - the HAVING expression
44
- * @param {Record<string, any>} row - the aggregated result row
12
+ * @param {AsyncRow} row - the aggregated result row
45
13
  * @param {AsyncRow[]} group - the group of rows for re-evaluating aggregates
46
14
  * @param {Record<string, AsyncDataSource>} tables
47
15
  * @returns {Promise<boolean>} whether the HAVING condition is satisfied
48
16
  */
49
17
  export async function evaluateHavingExpr(expr, row, group, tables) {
50
- const context = createHavingContext(row, group)
18
+ // Having context
19
+ const context = { ...group[0] ?? {}, ...row }
51
20
 
52
21
  // For HAVING, we need special handling of aggregate functions
53
22
  // They need to be re-evaluated against the group
@@ -0,0 +1,357 @@
1
+ import { evaluateExpr } from './expression.js'
2
+
3
+ /**
4
+ * @import { AsyncRow, AsyncDataSource, JoinClause, ExprNode } from '../types.js'
5
+ */
6
+
7
+ /**
8
+ * Executes JOIN operations against a base data source
9
+ *
10
+ * @param {AsyncDataSource} leftSource - the left side of the join (FROM table)
11
+ * @param {JoinClause[]} joins - array of join clauses to execute
12
+ * @param {string} leftTableName - name of the left table (for column prefixing)
13
+ * @param {Record<string, AsyncDataSource>} tables - all available tables
14
+ * @returns {Promise<AsyncDataSource>} data source yielding joined rows
15
+ */
16
+ export async function executeJoins(leftSource, joins, leftTableName, tables) {
17
+ let currentLeftTable = leftTableName
18
+
19
+ // Single join optimization: stream left rows without buffering
20
+ if (joins.length === 1) {
21
+ const join = joins[0]
22
+ const rightSource = tables[join.table]
23
+ if (rightSource === undefined) {
24
+ throw new Error(`Table "${join.table}" not found`)
25
+ }
26
+
27
+ // Buffer right rows for hash index (required for hash join)
28
+ /** @type {AsyncRow[]} */
29
+ const rightRows = []
30
+ for await (const row of rightSource.getRows()) {
31
+ rightRows.push(row)
32
+ }
33
+
34
+ // Use alias for column prefixing if present
35
+ const rightTableName = join.alias ?? join.table
36
+
37
+ // Return streaming data source - left rows stream through without buffering
38
+ return {
39
+ async *getRows() {
40
+ yield* hashJoin({
41
+ leftRows: leftSource.getRows(), // Stream directly, not buffered
42
+ rightRows,
43
+ join,
44
+ leftTable: currentLeftTable,
45
+ rightTable: rightTableName,
46
+ tables,
47
+ })
48
+ },
49
+ }
50
+ }
51
+
52
+ // Multiple joins: buffer intermediate results, stream final join
53
+ /** @type {AsyncRow[]} */
54
+ let leftRows = []
55
+ for await (const row of leftSource.getRows()) {
56
+ leftRows.push(row)
57
+ }
58
+
59
+ // Process all but the last join, buffering intermediate results
60
+ for (let i = 0; i < joins.length - 1; i++) {
61
+ const join = joins[i]
62
+ const rightSource = tables[join.table]
63
+ if (rightSource === undefined) {
64
+ throw new Error(`Table "${join.table}" not found`)
65
+ }
66
+
67
+ /** @type {AsyncRow[]} */
68
+ const rightRows = []
69
+ for await (const row of rightSource.getRows()) {
70
+ rightRows.push(row)
71
+ }
72
+
73
+ // Use alias for column prefixing if present
74
+ const rightTableName = join.alias ?? join.table
75
+
76
+ // Collect intermediate results into array for next join
77
+ /** @type {AsyncRow[]} */
78
+ const newLeftRows = []
79
+ const joined = hashJoin({
80
+ leftRows,
81
+ rightRows,
82
+ join,
83
+ leftTable: currentLeftTable,
84
+ rightTable: rightTableName,
85
+ tables,
86
+ })
87
+ for await (const row of joined) {
88
+ newLeftRows.push(row)
89
+ }
90
+ leftRows = newLeftRows
91
+
92
+ // After join, the "left" table for the next join includes all joined tables
93
+ currentLeftTable = `${currentLeftTable}_${rightTableName}`
94
+ }
95
+
96
+ // Final join: stream the results
97
+ const lastJoin = joins[joins.length - 1]
98
+ const rightSource = tables[lastJoin.table]
99
+ if (rightSource === undefined) {
100
+ throw new Error(`Table "${lastJoin.table}" not found`)
101
+ }
102
+
103
+ /** @type {AsyncRow[]} */
104
+ const rightRows = []
105
+ for await (const row of rightSource.getRows()) {
106
+ rightRows.push(row)
107
+ }
108
+
109
+ // Use alias for column prefixing if present
110
+ const lastRightTableName = lastJoin.alias ?? lastJoin.table
111
+
112
+ return {
113
+ async *getRows() {
114
+ yield* hashJoin({
115
+ leftRows,
116
+ rightRows,
117
+ join: lastJoin,
118
+ leftTable: currentLeftTable,
119
+ rightTable: lastRightTableName,
120
+ tables,
121
+ })
122
+ },
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Checks if an expression references a specific table.
128
+ * Returns true if the expression is an identifier prefixed with the table name.
129
+ *
130
+ * @param {ExprNode} expr
131
+ * @param {string} tableName
132
+ * @returns {boolean}
133
+ */
134
+ function exprReferencesTable(expr, tableName) {
135
+ return expr.type === 'identifier' && expr.name.startsWith(`${tableName}.`)
136
+ }
137
+
138
+ /**
139
+ * Extracts the join key expressions from an ON condition.
140
+ * Handles both `left.col = right.col` and `right.col = left.col` orderings.
141
+ *
142
+ * @param {ExprNode} onCondition
143
+ * @param {string} leftTable
144
+ * @param {string} rightTable
145
+ * @returns {{ leftKey: ExprNode, rightKey: ExprNode } | undefined}
146
+ */
147
+ function extractJoinKeys(onCondition, leftTable, rightTable) {
148
+ if (onCondition.type === 'binary' && onCondition.op === '=') {
149
+ const { left, right } = onCondition
150
+
151
+ // Check if keys are swapped (right table referenced in left position)
152
+ const leftRefsRight = exprReferencesTable(left, rightTable)
153
+ const rightRefsLeft = exprReferencesTable(right, leftTable)
154
+
155
+ if (leftRefsRight && rightRefsLeft) {
156
+ // Keys are swapped, return them in correct order
157
+ return { leftKey: right, rightKey: left }
158
+ }
159
+
160
+ // Default: assume left operand is for left table
161
+ return { leftKey: left, rightKey: right }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Creates a NULL-filled row with the given column names
167
+ *
168
+ * @param {string[]} columnNames
169
+ * @returns {AsyncRow}
170
+ */
171
+ function createNullRow(columnNames) {
172
+ /** @type {AsyncRow} */
173
+ const row = {}
174
+ for (const col of columnNames) {
175
+ row[col] = () => Promise.resolve(null)
176
+ }
177
+ return row
178
+ }
179
+
180
+ /**
181
+ * Merges two rows into one, prefixing columns with table names
182
+ *
183
+ * @param {AsyncRow} leftRow
184
+ * @param {AsyncRow} rightRow
185
+ * @param {string} leftTable
186
+ * @param {string} rightTable
187
+ * @returns {AsyncRow}
188
+ */
189
+ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
190
+ /** @type {AsyncRow} */
191
+ const merged = {}
192
+
193
+ // Add left table columns with prefix
194
+ for (const [key, cell] of Object.entries(leftRow)) {
195
+ // Skip already-prefixed keys (from previous joins)
196
+ if (!key.includes('.')) {
197
+ merged[`${leftTable}.${key}`] = cell
198
+ } else {
199
+ merged[key] = cell
200
+ }
201
+ // Also keep unqualified name for convenience (may be overwritten if ambiguous)
202
+ merged[key] = cell
203
+ }
204
+
205
+ // Add right table columns with prefix
206
+ for (const [key, cell] of Object.entries(rightRow)) {
207
+ if (!key.includes('.')) {
208
+ merged[`${rightTable}.${key}`] = cell
209
+ } else {
210
+ merged[key] = cell
211
+ }
212
+ // Unqualified name (overwrites if same name exists in left table)
213
+ merged[key] = cell
214
+ }
215
+
216
+ return merged
217
+ }
218
+
219
+ /**
220
+ * Performs a hash join between left and right row sets (streaming).
221
+ * Yields rows as they are found instead of buffering all results.
222
+ *
223
+ * @param {Object} params
224
+ * @param {AsyncIterable<AsyncRow>|AsyncRow[]} params.leftRows - rows from left table (can stream)
225
+ * @param {AsyncRow[]} params.rightRows - rows from right table (must be buffered for hash index)
226
+ * @param {JoinClause} params.join - join specification
227
+ * @param {string} params.leftTable - name of left table (for column prefixing)
228
+ * @param {string} params.rightTable - name of right table (for column prefixing, may be alias)
229
+ * @param {Record<string, AsyncDataSource>} params.tables - all tables for expression evaluation
230
+ * @yields {AsyncRow} joined rows
231
+ */
232
+ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tables }) {
233
+ const { joinType, on: onCondition } = join
234
+
235
+ if (!onCondition) {
236
+ throw new Error('JOIN requires ON condition')
237
+ }
238
+
239
+ const keys = extractJoinKeys(onCondition, leftTable, rightTable)
240
+
241
+ // Get column names for NULL row generation (right side is always buffered)
242
+ const rightCols = rightRows.length ? Object.keys(rightRows[0]) : []
243
+ const rightPrefixedCols = rightCols.flatMap(col =>
244
+ col.includes('.') ? [col] : [`${rightTable}.${col}`, col]
245
+ )
246
+
247
+ // Track left column info - captured from first row during iteration
248
+ /** @type {string[]|null} */
249
+ let leftPrefixedCols = null
250
+
251
+ if (keys) {
252
+ // Hash join: build hash map on right table
253
+ /** @type {Map<string, AsyncRow[]>} */
254
+ const hashMap = new Map()
255
+
256
+ // BUILD PHASE: Index right rows by join key
257
+ // Skip null keys - SQL semantics: NULL != NULL
258
+ for (const rightRow of rightRows) {
259
+ const keyValue = await evaluateExpr({ node: keys.rightKey, row: rightRow, tables })
260
+ if (keyValue == null) continue // NULL keys never match
261
+ const keyStr = JSON.stringify(keyValue)
262
+
263
+ let bucket = hashMap.get(keyStr)
264
+ if (!bucket) {
265
+ bucket = []
266
+ hashMap.set(keyStr, bucket)
267
+ }
268
+ bucket.push(rightRow)
269
+ }
270
+
271
+ // Track which right rows matched (only needed for RIGHT/FULL joins)
272
+ /** @type {Set<AsyncRow>|null} */
273
+ const matchedRightRows = joinType === 'RIGHT' || joinType === 'FULL' ? new Set() : null
274
+
275
+ // PROBE PHASE: Stream through left rows, yield matches immediately
276
+ for await (const leftRow of leftRows) {
277
+ // Capture left column info from first row (for NULL row generation)
278
+ if (!leftPrefixedCols) {
279
+ const leftCols = Object.keys(leftRow)
280
+ leftPrefixedCols = leftCols.flatMap(col =>
281
+ col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
282
+ )
283
+ }
284
+
285
+ const keyValue = await evaluateExpr({ node: keys.leftKey, row: leftRow, tables })
286
+ const keyStr = JSON.stringify(keyValue)
287
+
288
+ const matchingRightRows = hashMap.get(keyStr)
289
+
290
+ if (matchingRightRows && matchingRightRows.length > 0) {
291
+ for (const rightRow of matchingRightRows) {
292
+ if (matchedRightRows) matchedRightRows.add(rightRow)
293
+ yield mergeRows(leftRow, rightRow, leftTable, rightTable)
294
+ }
295
+ } else if (joinType === 'LEFT' || joinType === 'FULL') {
296
+ const nullRight = createNullRow(rightPrefixedCols)
297
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
298
+ }
299
+ // INNER join with no match: don't yield anything
300
+ }
301
+
302
+ // UNMATCHED PHASE: Handle unmatched right rows for RIGHT/FULL joins
303
+ if (matchedRightRows) {
304
+ for (const rightRow of rightRows) {
305
+ if (!matchedRightRows.has(rightRow)) {
306
+ // Use empty array if left table was empty (no rows to derive columns from)
307
+ const nullLeft = createNullRow(leftPrefixedCols || [])
308
+ yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
309
+ }
310
+ }
311
+ }
312
+ } else {
313
+ // Fallback to nested loop for complex ON conditions
314
+ // Left rows stream through, right rows are iterated for each left row
315
+ /** @type {Set<AsyncRow>|null} */
316
+ const matchedRightRows = joinType === 'RIGHT' || joinType === 'FULL' ? new Set() : null
317
+
318
+ for await (const leftRow of leftRows) {
319
+ // Capture left column info from first row (for NULL row generation)
320
+ if (!leftPrefixedCols) {
321
+ const leftCols = Object.keys(leftRow)
322
+ leftPrefixedCols = leftCols.flatMap(col =>
323
+ col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
324
+ )
325
+ }
326
+
327
+ let hasMatch = false
328
+
329
+ for (const rightRow of rightRows) {
330
+ const tempMerged = mergeRows(leftRow, rightRow, leftTable, rightTable)
331
+ const matches = await evaluateExpr({ node: onCondition, row: tempMerged, tables })
332
+
333
+ if (matches) {
334
+ hasMatch = true
335
+ if (matchedRightRows) matchedRightRows.add(rightRow)
336
+ yield tempMerged
337
+ }
338
+ }
339
+
340
+ if (!hasMatch && (joinType === 'LEFT' || joinType === 'FULL')) {
341
+ const nullRight = createNullRow(rightPrefixedCols)
342
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
343
+ }
344
+ }
345
+
346
+ // Handle unmatched right rows for RIGHT/FULL joins
347
+ if (matchedRightRows) {
348
+ for (const rightRow of rightRows) {
349
+ if (!matchedRightRows.has(rightRow)) {
350
+ // Use empty array if left table was empty (no rows to derive columns from)
351
+ const nullLeft = createNullRow(leftPrefixedCols || [])
352
+ yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
@@ -1,14 +1,52 @@
1
1
  /**
2
- * Collects all results from an async generator into an array
2
+ * Collects and materialize all results from an async row generator into an array
3
3
  *
4
- * @template T
5
- * @param {AsyncGenerator<T>} asyncGen - the async generator
6
- * @returns {Promise<T[]>} array of all yielded values
4
+ * @import {AsyncRow, ExprNode, SqlPrimitive} from '../types.js'
5
+ * @param {AsyncGenerator<AsyncRow>} asyncRows
6
+ * @returns {Promise<Record<string, SqlPrimitive>[]>} array of all yielded values
7
7
  */
8
- export async function collect(asyncGen) {
8
+ export async function collect(asyncRows) {
9
+ /** @type {Record<string, SqlPrimitive>[]} */
9
10
  const results = []
10
- for await (const item of asyncGen) {
11
+ for await (const asyncRow of asyncRows) {
12
+ /** @type {Record<string, SqlPrimitive>} */
13
+ const item = {}
14
+ for (const [key, cell] of Object.entries(asyncRow)) {
15
+ item[key] = await cell()
16
+ }
11
17
  results.push(item)
12
18
  }
13
19
  return results
14
20
  }
21
+
22
+ /**
23
+ * Generates a default alias for a derived column expression
24
+ *
25
+ * @param {ExprNode} expr - the expression node
26
+ * @returns {string} the generated alias
27
+ */
28
+ export function defaultDerivedAlias(expr) {
29
+ if (expr.type === 'identifier') {
30
+ // For qualified names like 'users.name', use just the column part as alias
31
+ if (expr.name.includes('.')) {
32
+ return expr.name.split('.').pop()
33
+ }
34
+ return expr.name
35
+ }
36
+ if (expr.type === 'literal') {
37
+ return String(expr.value)
38
+ }
39
+ if (expr.type === 'cast') {
40
+ return defaultDerivedAlias(expr.expr) + '_as_' + expr.toType
41
+ }
42
+ if (expr.type === 'unary') {
43
+ return expr.op + '_' + defaultDerivedAlias(expr.argument)
44
+ }
45
+ if (expr.type === 'binary') {
46
+ return defaultDerivedAlias(expr.left) + '_' + expr.op + '_' + defaultDerivedAlias(expr.right)
47
+ }
48
+ if (expr.type === 'function') {
49
+ return expr.name.toLowerCase() + '_' + expr.args.map(defaultDerivedAlias).join('_')
50
+ }
51
+ return 'expr'
52
+ }
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExecuteSqlOptions, SelectStatement } from './types.js'
1
+ import type { AsyncRow, ExecuteSqlOptions, SelectStatement, SqlPrimitive } from './types.js'
2
2
 
3
3
  /**
4
4
  * Executes a SQL SELECT query against an array of data rows
@@ -8,12 +8,12 @@ import type { ExecuteSqlOptions, SelectStatement } from './types.js'
8
8
  * @param options.query - SQL query string
9
9
  * @returns async generator yielding rows matching the query
10
10
  */
11
- export function executeSql(options: ExecuteSqlOptions): AsyncGenerator<Record<string, any>>
11
+ export function executeSql(options: ExecuteSqlOptions): AsyncGenerator<AsyncRow>
12
12
 
13
13
  /**
14
14
  * Parses a SQL query string into an abstract syntax tree
15
15
  *
16
- * @param sql - SQL query string to parse
16
+ * @param query - SQL query string to parse
17
17
  * @returns parsed SQL select statement
18
18
  */
19
19
  export function parseSql(query: string): SelectStatement
@@ -24,4 +24,4 @@ export function parseSql(query: string): SelectStatement
24
24
  * @param asyncGen - the async generator
25
25
  * @returns array of all yielded values
26
26
  */
27
- export function collect<T>(asyncGen: AsyncGenerator<T>): Promise<T[]>
27
+ export function collect<T>(asyncGen: AsyncGenerator<AsyncRow>): Promise<Record<string, SqlPrimitive>[]>
@@ -1,7 +1,7 @@
1
1
  import { isAggregateFunc, isStringFunc } from '../validation.js'
2
2
 
3
3
  /**
4
- * @import { ExprCursor, ExprNode, BinaryOp } from '../types.js'
4
+ * @import { BinaryOp, ExprCursor, ExprNode, WhenClause } from '../types.js'
5
5
  */
6
6
 
7
7
  /**
@@ -153,7 +153,7 @@ function parsePrimary(c) {
153
153
  c.consume() // CASE
154
154
 
155
155
  // Check if it's simple CASE (CASE expr WHEN ...) or searched CASE (CASE WHEN ...)
156
- /** @type {import('../types.js').ExprNode | undefined} */
156
+ /** @type {ExprNode | undefined} */
157
157
  let caseExpr
158
158
  const nextTok = c.current()
159
159
  if (nextTok.type !== 'keyword' || nextTok.value !== 'WHEN') {
@@ -162,7 +162,7 @@ function parsePrimary(c) {
162
162
  }
163
163
 
164
164
  // Parse WHEN clauses
165
- /** @type {import('../types.js').WhenClause[]} */
165
+ /** @type {WhenClause[]} */
166
166
  const whenClauses = []
167
167
  while (c.match('keyword', 'WHEN')) {
168
168
  const condition = parseExpression(c)
@@ -176,7 +176,7 @@ function parsePrimary(c) {
176
176
  }
177
177
 
178
178
  // Parse optional ELSE clause
179
- /** @type {import('../types.js').ExprNode | undefined} */
179
+ /** @type {ExprNode | undefined} */
180
180
  let elseResult
181
181
  if (c.match('keyword', 'ELSE')) {
182
182
  elseResult = parseExpression(c)
@@ -3,7 +3,7 @@ import { parseExpression } from './expression.js'
3
3
  import { isAggregateFunc } from '../validation.js'
4
4
 
5
5
  /**
6
- * @import { AggregateColumn, AggregateArg, AggregateFunc, ExprCursor, ExprNode, FromSubquery, JoinClause, JoinType, OrderByItem, ParserState, SelectStatement, SelectColumn, Token, TokenType } from '../types.js'
6
+ * @import { AggregateColumn, AggregateArg, AggregateFunc, ExprCursor, ExprNode, FromSubquery, FromTable, JoinClause, JoinType, OrderByItem, ParserState, SelectStatement, SelectColumn, Token, TokenType } from '../types.js'
7
7
  */
8
8
 
9
9
  // Keywords that cannot be used as implicit aliases after a column
@@ -17,6 +17,23 @@ const RESERVED_AFTER_COLUMN = new Set([
17
17
  'OFFSET',
18
18
  ])
19
19
 
20
+ // Keywords that cannot be used as table aliases
21
+ const RESERVED_AFTER_TABLE = new Set([
22
+ 'WHERE',
23
+ 'GROUP',
24
+ 'HAVING',
25
+ 'ORDER',
26
+ 'LIMIT',
27
+ 'OFFSET',
28
+ 'JOIN',
29
+ 'INNER',
30
+ 'LEFT',
31
+ 'RIGHT',
32
+ 'FULL',
33
+ 'CROSS',
34
+ 'ON',
35
+ ])
36
+
20
37
  /**
21
38
  * @param {string} query
22
39
  * @returns {SelectStatement}
@@ -219,17 +236,24 @@ function parseAggregateItem(state, func) {
219
236
  const cursor = createExprCursor(state)
220
237
  const expr = parseExpression(cursor)
221
238
  expect(state, 'keyword', 'AS')
222
- const typeTok = expectIdentifier(state)
239
+ const toType = expectIdentifier(state).value
223
240
  expect(state, 'paren', ')')
224
241
  arg = {
225
242
  kind: 'expression',
226
- expr: { type: 'cast', expr, toType: typeTok.value },
243
+ expr: { type: 'cast', expr, toType },
227
244
  }
228
245
  } else {
229
- const colTok = expectIdentifier(state)
246
+ // column name
247
+ let name = expectIdentifier(state).value
248
+ // Handle qualified column names like orders.amount
249
+ if (current(state).type === 'dot') {
250
+ consume(state) // consume dot
251
+ const qualifiedPart = expectIdentifier(state)
252
+ name = `${name}.${qualifiedPart.value}`
253
+ }
230
254
  arg = {
231
255
  kind: 'expression',
232
- expr: { type: 'identifier', name: colTok.value },
256
+ expr: { type: 'identifier', name },
233
257
  }
234
258
  }
235
259
 
@@ -240,6 +264,25 @@ function parseAggregateItem(state, func) {
240
264
  return { kind: 'aggregate', func, arg, alias }
241
265
  }
242
266
 
267
+ /**
268
+ * Parses an optional table alias (e.g., "FROM users u" or "FROM users AS u")
269
+ * @param {ParserState} state
270
+ * @returns {string | undefined}
271
+ */
272
+ function parseTableAlias(state) {
273
+ // Check for explicit AS keyword
274
+ if (match(state, 'keyword', 'AS')) {
275
+ const aliasTok = expectIdentifier(state)
276
+ return aliasTok.value
277
+ }
278
+ // Check for implicit alias (identifier not in reserved list)
279
+ const maybeAlias = current(state)
280
+ if (maybeAlias.type === 'identifier' && !RESERVED_AFTER_TABLE.has(maybeAlias.value.toUpperCase())) {
281
+ consume(state)
282
+ return maybeAlias.value
283
+ }
284
+ }
285
+
243
286
  /**
244
287
  * @param {ParserState} state
245
288
  * @returns {string | undefined}
@@ -322,9 +365,9 @@ function parseJoins(state) {
322
365
  break
323
366
  }
324
367
 
325
- // Parse table name
326
- const tableTok = expectIdentifier(state)
327
- const tableName = tableTok.value
368
+ // Parse table name and optional alias
369
+ const tableName = expectIdentifier(state).value
370
+ const tableAlias = parseTableAlias(state)
328
371
 
329
372
  // Parse ON condition
330
373
  expect(state, 'keyword', 'ON')
@@ -332,8 +375,9 @@ function parseJoins(state) {
332
375
  const condition = parseExpression(cursor)
333
376
 
334
377
  joins.push({
335
- type: joinType,
378
+ joinType,
336
379
  table: tableName,
380
+ alias: tableAlias,
337
381
  on: condition,
338
382
  })
339
383
  }
@@ -351,12 +395,8 @@ function parseSubquery(state) {
351
395
  const query = parseSelectInternal(state)
352
396
  expect(state, 'paren', ')')
353
397
  expect(state, 'keyword', 'AS')
354
- const aliasTok = expectIdentifier(state)
355
- return {
356
- kind: 'subquery',
357
- query,
358
- alias: aliasTok.value,
359
- }
398
+ const alias = expectIdentifier(state).value
399
+ return { kind: 'subquery', query, alias }
360
400
  }
361
401
 
362
402
  /**
@@ -376,6 +416,7 @@ function parseSelectInternal(state) {
376
416
  expect(state, 'keyword', 'FROM')
377
417
 
378
418
  // Check if it's a subquery or table name
419
+ /** @type {FromTable | FromSubquery} */
379
420
  let from
380
421
  const tok = current(state)
381
422
  if (tok.type === 'paren' && tok.value === '(') {
@@ -383,7 +424,9 @@ function parseSelectInternal(state) {
383
424
  from = parseSubquery(state)
384
425
  } else {
385
426
  // Simple table name: SELECT * FROM users
386
- from = expectIdentifier(state).value
427
+ const table = expectIdentifier(state).value
428
+ const alias = parseTableAlias(state)
429
+ from = { kind: 'table', table, alias }
387
430
  }
388
431
 
389
432
  // Parse JOIN clauses