squirreling 0.11.0 → 0.11.2

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.0",
3
+ "version": "0.11.2",
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 {
@@ -161,16 +165,17 @@ export async function* executeScalarAggregate(plan, context) {
161
165
  * @param {ExecuteContext} context
162
166
  * @returns {AsyncGenerator<AsyncRow> | undefined}
163
167
  */
164
- function tryColumnScanAggregate(plan, context) {
168
+ function tryColumnScanAggregate(plan, { tables, signal }) {
165
169
  // No HAVING support in fast path
166
170
  if (plan.having) return
167
171
  // Child must be a direct table scan
168
172
  if (plan.child.type !== 'Scan') return
169
173
  const scanNode = plan.child
170
- // No WHERE in scan (scanColumn doesn't support filtering)
171
- if (scanNode.hints.where) return
174
+ const { limit, offset, where } = scanNode.hints
175
+ // scanColumn doesn't support filtering
176
+ if (where) return
172
177
 
173
- const table = context.tables[scanNode.table]
178
+ const table = tables[scanNode.table]
174
179
  if (!table?.scanColumn) return
175
180
 
176
181
  // All columns must be simple aggregates on plain identifiers
@@ -190,9 +195,8 @@ function tryColumnScanAggregate(plan, context) {
190
195
  const cells = {}
191
196
 
192
197
  for (const spec of specs) {
193
- const value = await scanColumnAggregate(table, spec, context.signal)
194
198
  columns.push(spec.alias)
195
- cells[spec.alias] = () => Promise.resolve(value)
199
+ cells[spec.alias] = () => scanColumnAggregate({ table, spec, limit, offset, signal })
196
200
  }
197
201
 
198
202
  yield { columns, cells }
@@ -226,13 +230,16 @@ function extractColumnAggSpec({ expr, alias }) {
226
230
  /**
227
231
  * Scans a single column and computes an aggregate value.
228
232
  *
229
- * @param {AsyncDataSource} table
230
- * @param {ColumnAggSpec} spec
231
- * @param {AbortSignal} [signal]
233
+ * @param {Object} options
234
+ * @param {AsyncDataSource} options.table
235
+ * @param {ColumnAggSpec} options.spec
236
+ * @param {number} [options.limit]
237
+ * @param {number} [options.offset]
238
+ * @param {AbortSignal} [options.signal]
232
239
  * @returns {Promise<SqlPrimitive>}
233
240
  */
234
- async function scanColumnAggregate(table, spec, signal) {
235
- const values = table.scanColumn({ column: spec.column, signal })
241
+ async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
242
+ const values = table.scanColumn({ column: spec.column, limit, offset, signal })
236
243
 
237
244
  if (spec.funcName === 'COUNT' && spec.distinct) {
238
245
  const seen = new Set()
@@ -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,16 +95,8 @@ 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(', ') || '[]'}`)
107
- }
98
+ const table = validateTable({ ...plan, tables })
99
+ validateScan({ ...plan, tables })
108
100
 
109
101
  // do the scan
110
102
  const { rows, appliedWhere, appliedLimitOffset } = table.scan({ ...plan.hints, signal })
@@ -138,10 +130,7 @@ async function* executeScan(plan, context) {
138
130
  * @yields {AsyncRow}
139
131
  */
140
132
  async function* executeCount(plan, { tables, signal }) {
141
- const table = tables[plan.table]
142
- if (!table) {
143
- throw new TableNotFoundError({ table: plan.table, tables })
144
- }
133
+ const table = validateTable({ ...plan, tables })
145
134
 
146
135
  // Use source numRows if available
147
136
  let count = table.numRows
@@ -273,9 +262,14 @@ async function* executeProject(plan, context) {
273
262
 
274
263
  for (const col of plan.columns) {
275
264
  if (col.type === 'star') {
265
+ const prefix = col.table ? `${col.table}.` : undefined
276
266
  for (const key of row.columns) {
277
- columns.push(key)
278
- cells[key] = row.cells[key]
267
+ if (prefix && !key.startsWith(prefix)) continue
268
+ // Strip table prefix for output column names
269
+ const dotIndex = key.indexOf('.')
270
+ const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
271
+ columns.push(outputKey)
272
+ cells[outputKey] = row.cells[key]
279
273
  }
280
274
  } else {
281
275
  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,
@@ -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
  }
@@ -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,16 @@ 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
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) {
119
+ if (col.type === 'derived') {
120
+ validateTableRefs(col.expr, scopeTables)
121
+ } else if (col.table && !(col.table in scopeTables)) {
122
+ throw new TableNotFoundError({ table: col.table, tables: scopeTables })
123
+ }
124
+ }
125
+
115
126
  // Determine scan hints for direct table scans (WHERE and LIMIT/OFFSET are
116
127
  // included so they are only applied to fresh scans, not CTE/subquery plans)
117
128
  /** @type {ScanOptions} */
@@ -191,13 +202,23 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns }) {
191
202
  // However, for streaming distinct we need to project first
192
203
  // So the order is: Sort -> Project -> Distinct -> Limit
193
204
 
194
- // Fast path for SELECT *
195
- const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star'
205
+ // Fast path for SELECT * without joins
206
+ const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length
196
207
  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
+ })
197
219
  // When parent only needs specific columns, drop unneeded projections
198
- let projectColumns = select.columns
199
220
  if (parentColumns) {
200
- projectColumns = select.columns.filter(col =>
221
+ projectColumns = projectColumns.filter(col =>
201
222
  col.type === 'star' || parentColumns.includes(col.alias ?? derivedAlias(col.expr))
202
223
  )
203
224
  }
@@ -240,11 +261,7 @@ function planFrom({ select, ctePlans, cteColumns, hints, tables }) {
240
261
  if (hints.columns && availableColumns.length) {
241
262
  const missingColumn = hints.columns.find(col => !availableColumns.includes(col))
242
263
  if (missingColumn) {
243
- throw new ColumnNotFoundError({
244
- columnName: missingColumn,
245
- availableColumns,
246
- ...select.from,
247
- })
264
+ throw new ColumnNotFoundError({ missingColumn, availableColumns, ...select.from })
248
265
  }
249
266
  }
250
267
  return subPlan
@@ -321,50 +338,44 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
321
338
  * Recursively replaces identifier nodes that match SELECT aliases
322
339
  * with their aliased expressions.
323
340
  *
324
- * @param {ExprNode} node
341
+ * @param {ExprNode | undefined} node
325
342
  * @param {Map<string, ExprNode>} aliases
326
343
  * @returns {ExprNode}
327
344
  */
328
345
  function resolveAliases(node, aliases) {
346
+ if (!node || !aliases.size) return node
329
347
  if (node.type === 'identifier') {
330
- const resolved = aliases.get(node.name)
331
- if (resolved) return resolved
332
- return node
348
+ return aliases.get(node.name) ?? node
333
349
  }
334
350
  if (node.type === 'unary') {
335
- const argument = resolveAliases(node.argument, aliases)
336
- return argument === node.argument ? node : { ...node, argument }
351
+ return { ...node, argument: resolveAliases(node.argument, aliases) }
337
352
  }
338
353
  if (node.type === 'binary') {
339
354
  const left = resolveAliases(node.left, aliases)
340
355
  const right = resolveAliases(node.right, aliases)
341
- return left === node.left && right === node.right ? node : { ...node, left, right }
356
+ return { ...node, left, right }
342
357
  }
343
358
  if (node.type === 'function') {
344
359
  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
360
+ return { ...node, args }
347
361
  }
348
362
  if (node.type === 'cast') {
349
- const expr = resolveAliases(node.expr, aliases)
350
- return expr === node.expr ? node : { ...node, expr }
363
+ return { ...node, expr: resolveAliases(node.expr, aliases) }
351
364
  }
352
365
  if (node.type === 'in valuelist') {
353
366
  const expr = resolveAliases(node.expr, aliases)
354
367
  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
368
+ return { ...node, expr, values }
357
369
  }
358
370
  if (node.type === 'case') {
359
- const caseExpr = node.caseExpr ? resolveAliases(node.caseExpr, aliases) : node.caseExpr
371
+ const caseExpr = resolveAliases(node.caseExpr, aliases)
360
372
  const whenClauses = node.whenClauses.map(w => {
361
373
  const condition = resolveAliases(w.condition, aliases)
362
374
  const result = resolveAliases(w.result, aliases)
363
- return condition === w.condition && result === w.result ? w : { ...w, condition, result }
375
+ return { ...w, condition, result }
364
376
  })
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
377
+ const elseResult = resolveAliases(node.elseResult, aliases)
378
+ return { ...node, caseExpr, whenClauses, elseResult }
368
379
  }
369
380
  // literal, interval, subquery, in, exists: no identifiers to resolve
370
381
  return node
@@ -396,33 +407,6 @@ function extractSimpleJoinKeys({ condition, leftTable, rightTable }) {
396
407
  return { leftKey: left, rightKey: right }
397
408
  }
398
409
 
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
410
  /**
427
411
  * Checks if every SELECT column is a plain COUNT(*).
428
412
  *
package/src/types.d.ts CHANGED
@@ -84,6 +84,8 @@ export interface ScanOptions {
84
84
  */
85
85
  export interface ScanColumnOptions {
86
86
  column: string
87
+ limit?: number
88
+ offset?: number
87
89
  signal?: AbortSignal
88
90
  }
89
91
 
@@ -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
+ }