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 +5 -5
- package/src/ast.d.ts +1 -0
- package/src/expression/binary.js +1 -1
- package/src/parse/extractTables.js +2 -0
- package/src/parse/joins.js +41 -7
- package/src/plan/columns.js +8 -0
- package/src/plan/plan.js +39 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.12.
|
|
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.
|
|
43
|
-
"@vitest/coverage-v8": "4.1.
|
|
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.
|
|
45
|
+
"eslint-plugin-jsdoc": "63.0.2",
|
|
46
46
|
"typescript": "6.0.3",
|
|
47
|
-
"vitest": "4.1.
|
|
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
|
package/src/expression/binary.js
CHANGED
|
@@ -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}$`, '
|
|
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
|
}
|
package/src/parse/joins.js
CHANGED
|
@@ -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
|
-
//
|
|
222
|
-
const
|
|
223
|
-
|
|
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:
|
|
282
|
+
table: tableName,
|
|
250
283
|
alias: tableAlias,
|
|
251
284
|
on: condition,
|
|
252
285
|
using,
|
|
286
|
+
subquery,
|
|
253
287
|
positionStart: tok.positionStart,
|
|
254
|
-
positionEnd:
|
|
288
|
+
positionEnd: endPos,
|
|
255
289
|
})
|
|
256
290
|
}
|
|
257
291
|
|
package/src/plan/columns.js
CHANGED
|
@@ -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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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 }
|