squirreling 0.11.1 → 0.11.3

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.1",
3
+ "version": "0.11.3",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -26,9 +26,13 @@ function projectAggregateColumns(selectColumns, group, context) {
26
26
  if (col.type === 'star') {
27
27
  const firstRow = group[0]
28
28
  if (firstRow) {
29
+ const prefix = col.table ? `${col.table}.` : undefined
29
30
  for (const key of firstRow.columns) {
30
- columns.push(key)
31
- cells[key] = firstRow.cells[key]
31
+ if (prefix && !key.startsWith(prefix)) continue
32
+ const dotIndex = key.indexOf('.')
33
+ const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
34
+ columns.push(outputKey)
35
+ cells[outputKey] = firstRow.cells[key]
32
36
  }
33
37
  }
34
38
  } else {
@@ -240,7 +244,7 @@ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
240
244
  if (spec.funcName === 'COUNT' && spec.distinct) {
241
245
  const seen = new Set()
242
246
  for await (const chunk of values) {
243
- if (signal?.aborted) return null
247
+ if (signal?.aborted) return
244
248
  for (let i = 0; i < chunk.length; i++) {
245
249
  const v = chunk[i]
246
250
  if (v == null) continue
@@ -253,7 +257,7 @@ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
253
257
  if (spec.funcName === 'COUNT') {
254
258
  let count = 0
255
259
  for await (const chunk of values) {
256
- if (signal?.aborted) return null
260
+ if (signal?.aborted) return
257
261
  for (let i = 0; i < chunk.length; i++) {
258
262
  if (chunk[i] != null) count++
259
263
  }
@@ -270,7 +274,7 @@ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
270
274
  let max = null
271
275
 
272
276
  for await (const chunk of values) {
273
- if (signal?.aborted) return null
277
+ if (signal?.aborted) return
274
278
  for (let i = 0; i < chunk.length; i++) {
275
279
  const v = chunk[i]
276
280
  if (v == null) continue
@@ -3,7 +3,7 @@ import { derivedAlias } from '../expression/alias.js'
3
3
  import { evaluateExpr } from '../expression/evaluate.js'
4
4
  import { parseSql } from '../parse/parse.js'
5
5
  import { planSql } from '../plan/plan.js'
6
- import { TableNotFoundError } from '../validation/planErrors.js'
6
+ import { validateScan, validateTable } from '../validation/tables.js'
7
7
  import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
8
8
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
9
9
  import { executeSort } from './sort.js'
@@ -95,15 +95,30 @@ export async function* executePlan({ plan, context }) {
95
95
  */
96
96
  async function* executeScan(plan, context) {
97
97
  const { tables, signal } = context
98
- // check table
99
- const table = tables[plan.table]
100
- if (!table) {
101
- throw new TableNotFoundError({ table: plan.table, tables })
102
- }
103
- // check columns
104
- const missingColumn = plan.hints.columns?.find(col => !table.columns.includes(col))
105
- if (missingColumn) {
106
- throw new Error(`Column "${missingColumn}" not found. Available columns: ${table.columns.join(', ') || '[]'}`)
98
+ const table = validateTable({ ...plan, tables })
99
+ validateScan({ ...plan, tables })
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
107
122
  }
108
123
 
109
124
  // do the scan
@@ -138,10 +153,7 @@ async function* executeScan(plan, context) {
138
153
  * @yields {AsyncRow}
139
154
  */
140
155
  async function* executeCount(plan, { tables, signal }) {
141
- const table = tables[plan.table]
142
- if (!table) {
143
- throw new TableNotFoundError({ table: plan.table, tables })
144
- }
156
+ const table = validateTable({ ...plan, tables })
145
157
 
146
158
  // Use source numRows if available
147
159
  let count = table.numRows
@@ -273,9 +285,14 @@ async function* executeProject(plan, context) {
273
285
 
274
286
  for (const col of plan.columns) {
275
287
  if (col.type === 'star') {
288
+ const prefix = col.table ? `${col.table}.` : undefined
276
289
  for (const key of row.columns) {
277
- columns.push(key)
278
- cells[key] = row.cells[key]
290
+ if (prefix && !key.startsWith(prefix)) continue
291
+ // Strip table prefix for output column names
292
+ const dotIndex = key.indexOf('.')
293
+ const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
294
+ columns.push(outputKey)
295
+ cells[outputKey] = row.cells[key]
279
296
  }
280
297
  } else {
281
298
  const alias = col.alias ?? derivedAlias(col.expr)
@@ -230,27 +230,16 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
230
230
 
231
231
  // Add left table columns with prefix
232
232
  for (const [key, cell] of Object.entries(leftRow.cells)) {
233
- // Skip already-prefixed keys (from previous joins)
234
- if (!key.includes('.')) {
235
- const alias = `${leftTable}.${key}`
236
- columns.push(alias)
237
- cells[alias] = cell
238
- }
239
- // Also keep unqualified name for convenience
240
- columns.push(key)
241
- cells[key] = cell
233
+ const alias = key.includes('.') ? key : `${leftTable}.${key}`
234
+ columns.push(alias)
235
+ cells[alias] = cell
242
236
  }
243
237
 
244
238
  // Add right table columns with prefix
245
239
  for (const [key, cell] of Object.entries(rightRow.cells)) {
246
- if (!key.includes('.')) {
247
- const alias = `${rightTable}.${key}`
248
- columns.push(alias)
249
- cells[alias] = cell
250
- }
251
- // Unqualified name (overwrites if same name exists in left table)
252
- columns.push(key)
253
- cells[key] = cell
240
+ const alias = key.includes('.') ? key : `${rightTable}.${key}`
241
+ columns.push(alias)
242
+ cells[alias] = cell
254
243
  }
255
244
 
256
245
  return { columns, cells }
@@ -264,5 +253,5 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
264
253
  * @returns {string[]}
265
254
  */
266
255
  function prefixColumns(cols, table) {
267
- return cols.flatMap(col => col.includes('.') ? [col] : [`${table}.${col}`, col])
256
+ return cols.map(col => col.includes('.') ? col : `${table}.${col}`)
268
257
  }
@@ -37,16 +37,24 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
37
37
  if (node.name in row.cells) {
38
38
  return row.cells[node.name]()
39
39
  }
40
- // For qualified names like 'users.id', also try just the column part
41
- if (node.name.includes('.')) {
42
- const colName = node.name.split('.').pop()
43
- if (colName && colName in row.cells) {
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) {
44
45
  return row.cells[colName]()
45
46
  }
47
+ } else {
48
+ // For unqualified names, search for a matching prefixed column (e.g. 'id' to 'a.id')
49
+ const suffix = '.' + node.name
50
+ const match = row.columns.find(col => col.endsWith(suffix))
51
+ if (match) {
52
+ return row.cells[match]()
53
+ }
46
54
  }
47
55
  // Unknown identifier
48
56
  throw new ColumnNotFoundError({
49
- columnName: node.name,
57
+ missingColumn: node.name,
50
58
  availableColumns: row.columns,
51
59
  rowIndex,
52
60
  ...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
  }
@@ -71,7 +71,8 @@ export function extractColumns({ select, parentColumns }) {
71
71
  const outputName = col.alias ?? derivedAlias(col.expr)
72
72
  if (!parentColumns.includes(outputName)) continue
73
73
  }
74
- collectColumnsFromExpr(col.expr, identifiers)
74
+ // Exclude earlier SELECT aliases so they aren't treated as source columns
75
+ collectColumnsFromExpr(col.expr, identifiers, selectAliases)
75
76
  if (col.alias) {
76
77
  selectAliases.add(col.alias)
77
78
  }
@@ -83,7 +84,7 @@ export function extractColumns({ select, parentColumns }) {
83
84
  collectColumnsFromExpr(item.expr, identifiers, selectAliases)
84
85
  }
85
86
  for (const expr of select.groupBy) {
86
- collectColumnsFromExpr(expr, identifiers)
87
+ collectColumnsFromExpr(expr, identifiers, selectAliases)
87
88
  }
88
89
  collectColumnsFromExpr(select.having, identifiers, selectAliases)
89
90
  for (const join of select.joins) {
@@ -223,12 +224,12 @@ function inferSelectSourceColumns({ select, cteColumns, tables }) {
223
224
  const result = []
224
225
  const fromAlias = select.from.alias ?? select.from.table
225
226
  for (const col of lookupTableColumns(select.from.table, cteColumns, tables)) {
226
- result.push(`${fromAlias}.${col}`, col)
227
+ result.push(`${fromAlias}.${col}`)
227
228
  }
228
229
  for (const join of select.joins) {
229
230
  const alias = join.alias ?? join.table
230
231
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
231
- result.push(`${alias}.${col}`, col)
232
+ result.push(`${alias}.${col}`)
232
233
  }
233
234
  }
234
235
  return result
package/src/plan/plan.js CHANGED
@@ -2,6 +2,7 @@ import { derivedAlias } from '../expression/alias.js'
2
2
  import { parseSql } from '../parse/parse.js'
3
3
  import { findAggregate } from '../validation/aggregates.js'
4
4
  import { ColumnNotFoundError, TableNotFoundError } from '../validation/planErrors.js'
5
+ import { validateScan, validateTableRefs } from '../validation/tables.js'
5
6
  import { extractColumns, fromAlias, inferStatementColumns } from './columns.js'
6
7
 
7
8
  /**
@@ -112,6 +113,26 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
112
113
  // Source alias for FROM clause
113
114
  const sourceAlias = fromAlias(select.from)
114
115
 
116
+ // Validate qualified references and resolve aliases
117
+ const scopeTables = Object.fromEntries([sourceAlias, ...select.joins.map(j => j.alias ?? j.table)].map(a => [a, true]))
118
+ /** @type {Map<string, ExprNode>} */
119
+ const aliases = new Map()
120
+ const columns = select.columns.map(col => {
121
+ if (col.type === 'derived') {
122
+ validateTableRefs(col.expr, 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)) {
131
+ throw new TableNotFoundError({ table: col.table, tables: scopeTables })
132
+ }
133
+ return col
134
+ })
135
+
115
136
  // Determine scan hints for direct table scans (WHERE and LIMIT/OFFSET are
116
137
  // included so they are only applied to fresh scans, not CTE/subquery plans)
117
138
  /** @type {ScanOptions} */
@@ -147,11 +168,15 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
147
168
  // Aggregation path: GROUP BY or scalar aggregate
148
169
  // HAVING is integrated into aggregate nodes for access to group context
149
170
  if (select.groupBy.length) {
150
- 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 }
151
176
  } else if (!select.having && !select.where && plan.type === 'Scan' && isOwnScan && isAllCountStar(select.columns)) {
152
177
  plan = { type: 'Count', table: plan.table, columns: select.columns }
153
178
  } else {
154
- plan = { type: 'ScalarAggregate', columns: select.columns, having: select.having, child: plan }
179
+ plan = { type: 'ScalarAggregate', columns, having: select.having, child: plan }
155
180
  }
156
181
 
157
182
  // ORDER BY (after aggregation)
@@ -174,13 +199,6 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
174
199
  // ORDER BY (before projection so it can access all columns)
175
200
  // Resolve SELECT aliases in ORDER BY expressions at plan time
176
201
  if (select.orderBy.length) {
177
- /** @type {Map<string, ExprNode>} */
178
- const aliases = new Map()
179
- for (const col of select.columns) {
180
- if (col.type === 'derived' && col.alias) {
181
- aliases.set(col.alias, col.expr)
182
- }
183
- }
184
202
  const orderBy = aliases.size > 0
185
203
  ? select.orderBy.map(term => ({ ...term, expr: resolveAliases(term.expr, aliases) }))
186
204
  : select.orderBy
@@ -191,13 +209,13 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
191
209
  // However, for streaming distinct we need to project first
192
210
  // So the order is: Sort -> Project -> Distinct -> Limit
193
211
 
194
- // Fast path for SELECT *
195
- const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star'
212
+ // Fast path for SELECT * without joins
213
+ const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length
196
214
  if (!isPassthrough) {
215
+ let projectColumns = columns
197
216
  // When parent only needs specific columns, drop unneeded projections
198
- let projectColumns = select.columns
199
217
  if (parentColumns) {
200
- projectColumns = select.columns.filter(col =>
218
+ projectColumns = projectColumns.filter(col =>
201
219
  col.type === 'star' || parentColumns.includes(col.alias ?? derivedAlias(col.expr))
202
220
  )
203
221
  }
@@ -240,11 +258,7 @@ function planFrom({ select, ctePlans, cteColumns, hints, tables }) {
240
258
  if (hints.columns && availableColumns.length) {
241
259
  const missingColumn = hints.columns.find(col => !availableColumns.includes(col))
242
260
  if (missingColumn) {
243
- throw new ColumnNotFoundError({
244
- columnName: missingColumn,
245
- availableColumns,
246
- ...select.from,
247
- })
261
+ throw new ColumnNotFoundError({ missingColumn, availableColumns, ...select.from })
248
262
  }
249
263
  }
250
264
  return subPlan
@@ -321,50 +335,44 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
321
335
  * Recursively replaces identifier nodes that match SELECT aliases
322
336
  * with their aliased expressions.
323
337
  *
324
- * @param {ExprNode} node
338
+ * @param {ExprNode | undefined} node
325
339
  * @param {Map<string, ExprNode>} aliases
326
340
  * @returns {ExprNode}
327
341
  */
328
342
  function resolveAliases(node, aliases) {
343
+ if (!node || !aliases.size) return node
329
344
  if (node.type === 'identifier') {
330
- const resolved = aliases.get(node.name)
331
- if (resolved) return resolved
332
- return node
345
+ return aliases.get(node.name) ?? node
333
346
  }
334
347
  if (node.type === 'unary') {
335
- const argument = resolveAliases(node.argument, aliases)
336
- return argument === node.argument ? node : { ...node, argument }
348
+ return { ...node, argument: resolveAliases(node.argument, aliases) }
337
349
  }
338
350
  if (node.type === 'binary') {
339
351
  const left = resolveAliases(node.left, aliases)
340
352
  const right = resolveAliases(node.right, aliases)
341
- return left === node.left && right === node.right ? node : { ...node, left, right }
353
+ return { ...node, left, right }
342
354
  }
343
355
  if (node.type === 'function') {
344
356
  const args = node.args.map(arg => resolveAliases(arg, aliases))
345
- const changed = args.some((arg, i) => arg !== node.args[i])
346
- return changed ? { ...node, args } : node
357
+ return { ...node, args }
347
358
  }
348
359
  if (node.type === 'cast') {
349
- const expr = resolveAliases(node.expr, aliases)
350
- return expr === node.expr ? node : { ...node, expr }
360
+ return { ...node, expr: resolveAliases(node.expr, aliases) }
351
361
  }
352
362
  if (node.type === 'in valuelist') {
353
363
  const expr = resolveAliases(node.expr, aliases)
354
364
  const values = node.values.map(v => resolveAliases(v, aliases))
355
- const changed = expr !== node.expr || values.some((v, i) => v !== node.values[i])
356
- return changed ? { ...node, expr, values } : node
365
+ return { ...node, expr, values }
357
366
  }
358
367
  if (node.type === 'case') {
359
- const caseExpr = node.caseExpr ? resolveAliases(node.caseExpr, aliases) : node.caseExpr
368
+ const caseExpr = resolveAliases(node.caseExpr, aliases)
360
369
  const whenClauses = node.whenClauses.map(w => {
361
370
  const condition = resolveAliases(w.condition, aliases)
362
371
  const result = resolveAliases(w.result, aliases)
363
- return condition === w.condition && result === w.result ? w : { ...w, condition, result }
372
+ return { ...w, condition, result }
364
373
  })
365
- const elseResult = node.elseResult ? resolveAliases(node.elseResult, aliases) : node.elseResult
366
- const changed = caseExpr !== node.caseExpr || elseResult !== node.elseResult || whenClauses.some((w, i) => w !== node.whenClauses[i])
367
- return changed ? { ...node, caseExpr, whenClauses, elseResult } : node
374
+ const elseResult = resolveAliases(node.elseResult, aliases)
375
+ return { ...node, caseExpr, whenClauses, elseResult }
368
376
  }
369
377
  // literal, interval, subquery, in, exists: no identifiers to resolve
370
378
  return node
@@ -396,33 +404,6 @@ function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
396
404
  return { leftKey: left, rightKey: right }
397
405
  }
398
406
 
399
- /**
400
- * Validates that a table exists and requested columns are available.
401
- *
402
- * @param {object} options
403
- * @param {string} options.table
404
- * @param {ScanOptions} options.hints
405
- * @param {Record<string, AsyncDataSource>} [options.tables]
406
- * @param {number} options.positionStart
407
- * @param {number} options.positionEnd
408
- */
409
- function validateScan({ table, hints, tables, positionStart, positionEnd }) {
410
- if (!tables) return
411
- const resolved = tables[table]
412
- if (!resolved) {
413
- throw new TableNotFoundError({ table, tables, positionStart, positionEnd })
414
- }
415
- const missingColumn = hints.columns?.find(col => !resolved.columns.includes(col))
416
- if (missingColumn) {
417
- throw new ColumnNotFoundError({
418
- columnName: missingColumn,
419
- availableColumns: resolved.columns,
420
- positionStart,
421
- positionEnd,
422
- })
423
- }
424
- }
425
-
426
407
  /**
427
408
  * Checks if every SELECT column is a plain COUNT(*).
428
409
  *
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'
@@ -30,18 +30,18 @@ export class TableNotFoundError extends ExecutionError {
30
30
  export class ColumnNotFoundError extends ExecutionError {
31
31
  /**
32
32
  * @param {Object} options
33
- * @param {string} options.columnName - The missing column name
33
+ * @param {string} options.missingColumn - The missing column name
34
34
  * @param {string[]} options.availableColumns - List of available column names
35
35
  * @param {number} options.positionStart
36
36
  * @param {number} options.positionEnd
37
37
  * @param {number} [options.rowIndex] - 1-based row number where error occurred
38
38
  */
39
- constructor({ columnName, availableColumns, positionStart, positionEnd, rowIndex }) {
39
+ constructor({ missingColumn, availableColumns, positionStart, positionEnd, rowIndex }) {
40
40
  const available = availableColumns.length > 0
41
41
  ? `. Available columns: ${availableColumns.join(', ')}`
42
42
  : ''
43
43
  super({
44
- message: `Column "${columnName}" not found${available}`,
44
+ message: `Column "${missingColumn}" not found${available}`,
45
45
  positionStart,
46
46
  positionEnd,
47
47
  rowIndex,
@@ -0,0 +1,89 @@
1
+ import { ColumnNotFoundError, TableNotFoundError } from './planErrors.js'
2
+
3
+ /**
4
+ * @import { AsyncDataSource, ExprNode, ScanOptions } from '../types.js'
5
+ */
6
+
7
+ /**
8
+ * @param {Object} options
9
+ * @param {string} options.table - The name of the table to validate
10
+ * @param {Record<string, AsyncDataSource>} options.tables - Object mapping table names to data sources
11
+ * @param {number} [options.positionStart] - Optional start position for error reporting
12
+ * @param {number} [options.positionEnd] - Optional end position for error reporting
13
+ * @returns {AsyncDataSource}
14
+ */
15
+ export function validateTable({ table, tables, positionStart, positionEnd } ) {
16
+ const resolved = tables[table]
17
+ if (!resolved) {
18
+ throw new TableNotFoundError({ table, tables, positionStart, positionEnd })
19
+ }
20
+ return resolved
21
+ }
22
+
23
+ /**
24
+ * Validates that a table exists and requested columns are available.
25
+ *
26
+ * @param {object} options
27
+ * @param {string} [options.table]
28
+ * @param {ScanOptions} options.hints
29
+ * @param {Record<string, AsyncDataSource>} [options.tables]
30
+ * @param {number} [options.positionStart]
31
+ * @param {number} [options.positionEnd]
32
+ */
33
+ export function validateScan({ table, hints, tables, positionStart, positionEnd }) {
34
+ if (!tables) return
35
+ const resolved = validateTable({ table, tables, positionStart, positionEnd })
36
+ const missingColumn = hints.columns?.find(col => !resolved.columns.includes(col))
37
+ if (missingColumn) {
38
+ throw new ColumnNotFoundError({
39
+ missingColumn,
40
+ availableColumns: resolved.columns,
41
+ positionStart,
42
+ positionEnd,
43
+ })
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Validates that qualified identifiers reference known table aliases.
49
+ *
50
+ * @param {ExprNode} expr
51
+ * @param {Record<string, any>} tables
52
+ */
53
+ export function validateTableRefs(expr, tables) {
54
+ if (!expr) return
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 })
61
+ }
62
+ }
63
+ return
64
+ }
65
+ if (expr.type === 'binary') {
66
+ validateTableRefs(expr.left, tables)
67
+ validateTableRefs(expr.right, tables)
68
+ } else if (expr.type === 'unary') {
69
+ validateTableRefs(expr.argument, tables)
70
+ } else if (expr.type === 'function') {
71
+ for (const arg of expr.args) {
72
+ validateTableRefs(arg, tables)
73
+ }
74
+ } else if (expr.type === 'cast') {
75
+ validateTableRefs(expr.expr, tables)
76
+ } else if (expr.type === 'in valuelist') {
77
+ validateTableRefs(expr.expr, tables)
78
+ for (const val of expr.values) {
79
+ validateTableRefs(val, tables)
80
+ }
81
+ } else if (expr.type === 'case') {
82
+ validateTableRefs(expr.caseExpr, tables)
83
+ for (const w of expr.whenClauses) {
84
+ validateTableRefs(w.condition, tables)
85
+ validateTableRefs(w.result, tables)
86
+ }
87
+ validateTableRefs(expr.elseResult, tables)
88
+ }
89
+ }