squirreling 0.12.23 → 0.12.24

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.12.23",
3
+ "version": "0.12.24",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -39,11 +39,11 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "25.9.1",
43
- "@vitest/coverage-v8": "4.1.7",
42
+ "@types/node": "25.9.3",
43
+ "@vitest/coverage-v8": "4.1.8",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.0",
45
+ "eslint-plugin-jsdoc": "63.0.2",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.7"
47
+ "vitest": "4.1.8"
48
48
  }
49
49
  }
package/src/ast.d.ts CHANGED
@@ -212,6 +212,7 @@ export interface JoinClause extends AstBase {
212
212
  on?: ExprNode
213
213
  using?: string[]
214
214
  fromFunction?: FromFunction
215
+ subquery?: FromSubquery
215
216
  }
216
217
 
217
218
  // All AST node derive from this base, which includes position info for error reporting and other purposes
@@ -55,7 +55,7 @@ export function applyBinaryOp(op, a, b) {
55
55
  .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
56
  .replace(/%/g, '.*')
57
57
  .replace(/_/g, '.')
58
- const regex = new RegExp(`^${regexPattern}$`, 'i')
58
+ const regex = new RegExp(`^${regexPattern}$`, 'is')
59
59
  return regex.test(str)
60
60
  }
61
61
 
@@ -59,6 +59,8 @@ function walkStatement(stmt, cteScope, refs) {
59
59
  for (const j of stmt.joins) {
60
60
  if (j.fromFunction) {
61
61
  for (const a of j.fromFunction.args) walkExpr(a, cteScope, refs)
62
+ } else if (j.subquery) {
63
+ walkStatement(j.subquery.query, cteScope, refs)
62
64
  } else if (!cteScope.has(j.table.toLowerCase())) {
63
65
  refs.add(j.table)
64
66
  }
@@ -2,11 +2,11 @@ import { expectNoAggregate } from '../validation/aggregates.js'
2
2
  import { isTableFunction, validateFunctionArgs } from '../validation/functions.js'
3
3
  import { ParseError } from '../validation/parseErrors.js'
4
4
  import { parseExpression } from './expression.js'
5
- import { isTableFunctionStart, parseFromFunction, parseTableAlias, tableFunctionColumnCount, tableFunctionDefaultColumns } from './parse.js'
5
+ import { isTableFunctionStart, parseFromFunction, parseStatement, parseTableAlias, tableFunctionColumnCount, tableFunctionDefaultColumns } from './parse.js'
6
6
  import { consume, current, expect, match } from './state.js'
7
7
 
8
8
  /**
9
- * @import { ExprNode, FromFunction, JoinClause, JoinType, ParserState } from '../types.js'
9
+ * @import { ExprNode, FromFunction, FromSubquery, JoinClause, JoinType, ParserState } from '../types.js'
10
10
  */
11
11
 
12
12
  /**
@@ -218,9 +218,42 @@ export function parseJoins(state) {
218
218
  })
219
219
  }
220
220
 
221
- // Parse table name and optional alias
222
- const tableTok = expect(state, 'identifier')
223
- const tableAlias = parseTableAlias(state)
221
+ // Subquery on the right side: JOIN (SELECT ...) AS alias ON ...
222
+ const rightTok = current(state)
223
+ /** @type {FromSubquery | undefined} */
224
+ let subquery
225
+ let tableName
226
+ /** @type {string | undefined} */
227
+ let tableAlias
228
+ let endPos
229
+ if (rightTok.type === 'paren' && rightTok.value === '(') {
230
+ consume(state)
231
+ const query = parseStatement(state)
232
+ expect(state, 'paren', ')')
233
+ tableAlias = parseTableAlias(state)
234
+ if (!tableAlias) {
235
+ throw new ParseError({
236
+ message: 'Subquery in JOIN must have an alias',
237
+ positionStart: rightTok.positionStart,
238
+ positionEnd: state.lastPos,
239
+ })
240
+ }
241
+ endPos = state.lastPos
242
+ subquery = {
243
+ type: 'subquery',
244
+ query,
245
+ alias: tableAlias,
246
+ positionStart: rightTok.positionStart,
247
+ positionEnd: endPos,
248
+ }
249
+ tableName = tableAlias
250
+ } else {
251
+ // Parse table name and optional alias
252
+ const tableTok = expect(state, 'identifier')
253
+ tableName = tableTok.value
254
+ tableAlias = parseTableAlias(state)
255
+ endPos = tableTok.positionEnd
256
+ }
224
257
 
225
258
  // Parse ON condition or USING column list (not for POSITIONAL joins)
226
259
  /** @type {ExprNode | undefined} */
@@ -246,12 +279,13 @@ export function parseJoins(state) {
246
279
 
247
280
  joins.push({
248
281
  joinType,
249
- table: tableTok.value,
282
+ table: tableName,
250
283
  alias: tableAlias,
251
284
  on: condition,
252
285
  using,
286
+ subquery,
253
287
  positionStart: tok.positionStart,
254
- positionEnd: tableTok.positionEnd,
288
+ positionEnd: endPos,
255
289
  })
256
290
  }
257
291
 
@@ -420,6 +420,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
420
420
  for (const col of tableFunctionColumnNames(join.fromFunction)) {
421
421
  result.push(`${joinAlias}.${col}`)
422
422
  }
423
+ } else if (join.subquery) {
424
+ for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
425
+ result.push(`${joinAlias}.${col}`)
426
+ }
423
427
  } else {
424
428
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
425
429
  result.push(`${joinAlias}.${col}`)
@@ -446,6 +450,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
446
450
  for (const col of tableFunctionColumnNames(join.fromFunction)) {
447
451
  result.push(`${joinAlias}.${col}`)
448
452
  }
453
+ } else if (join.subquery) {
454
+ for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
455
+ result.push(`${joinAlias}.${col}`)
456
+ }
449
457
  } else {
450
458
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
451
459
  result.push(`${joinAlias}.${col}`)
package/src/plan/plan.js CHANGED
@@ -447,18 +447,47 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
447
447
  continue
448
448
  }
449
449
 
450
- const ctePlan = ctePlans?.get(join.table.toLowerCase())
451
- /** @type {ScanOptions} */
452
- const rightHints = {}
453
- if (!ctePlan) {
454
- rightHints.columns = perTableColumns.get(rightTable)
455
- validateScan({ ...join, hints: rightHints, tables })
450
+ /** @type {QueryPlan} */
451
+ let rightScan
452
+ if (join.subquery) {
453
+ // Subquery on the right side of the join (derived table). Mirror the
454
+ // FROM-clause subquery handling: plan the inner statement, push down the
455
+ // columns the outer query needs, and wrap in the inner scope so
456
+ // correlated subqueries inside resolve against the right aliases.
457
+ let subColumns = perTableColumns.get(rightTable)
458
+ // Empty array means no columns referenced, but the derived table still
459
+ // needs its own columns. Treat empty as unrestricted.
460
+ if (subColumns?.length === 0) subColumns = undefined
461
+ const subPlan = planStatement({
462
+ stmt: join.subquery.query,
463
+ ctePlans,
464
+ cteColumns,
465
+ tables,
466
+ outerScope,
467
+ parentColumns: subColumns?.map(name => ({ type: 'identifier', name, positionStart: 0, positionEnd: 0 })),
468
+ })
469
+ const availableColumns = inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })
470
+ if (subColumns && availableColumns.length) {
471
+ const missingColumn = subColumns.find(col => !availableColumns.includes(col))
472
+ if (missingColumn) {
473
+ throw new ColumnNotFoundError({ missingColumn, availableColumns, ...join.subquery })
474
+ }
475
+ }
476
+ const innerScope = statementScope(join.subquery.query)
477
+ rightScan = innerScope ? { type: 'Subquery', scope: innerScope, child: subPlan } : subPlan
456
478
  } else {
457
- // For CTE joins, use CTE column metadata for hints
458
- rightHints.columns = perTableColumns.get(rightTable) ?? cteColumns?.get(join.table.toLowerCase())
479
+ const ctePlan = ctePlans?.get(join.table.toLowerCase())
480
+ /** @type {ScanOptions} */
481
+ const rightHints = {}
482
+ if (!ctePlan) {
483
+ rightHints.columns = perTableColumns.get(rightTable)
484
+ validateScan({ ...join, hints: rightHints, tables })
485
+ } else {
486
+ // For CTE joins, use CTE column metadata for hints
487
+ rightHints.columns = perTableColumns.get(rightTable) ?? cteColumns?.get(join.table.toLowerCase())
488
+ }
489
+ rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
459
490
  }
460
- /** @type {QueryPlan} */
461
- const rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
462
491
 
463
492
  if (join.joinType === 'POSITIONAL') {
464
493
  plan = { type: 'PositionalJoin', leftAlias: currentLeftTable, rightAlias: rightTable, left: plan, right: rightScan }