squirreling 0.12.23 → 0.12.25
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/README.md +2 -0
- package/package.json +5 -5
- package/src/ast.d.ts +1 -0
- package/src/backend/dataSource.js +18 -46
- package/src/execute/aggregates.js +104 -50
- package/src/execute/execute.js +7 -3
- package/src/execute/join.js +6 -2
- 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/README.md
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
Squirreling is a streaming async SQL engine in pure JavaScript. Built for the browser from the ground up: streaming input and output, pluggable data sources, and lazy async cell evaluation. This makes Squirreling ideal for querying data from network sources, APIs, or LLMs where latency and cost matter.
|
|
14
14
|
|
|
15
|
+
> Part of **[HypStack](https://hypstack.ai/)**, an open-source stack for AI observability.
|
|
16
|
+
|
|
15
17
|
- **Standard SQL**: Full SQL support for querying data (read-only)
|
|
16
18
|
- **Async UDFs**: User-defined functions can call APIs or models
|
|
17
19
|
- **Tiny**: 13 kb bundle, zero dependencies, instant startup
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.25",
|
|
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": "
|
|
43
|
-
"@vitest/coverage-v8": "4.1.
|
|
42
|
+
"@types/node": "26.0.1",
|
|
43
|
+
"@vitest/coverage-v8": "4.1.9",
|
|
44
44
|
"eslint": "9.39.4",
|
|
45
|
-
"eslint-plugin-jsdoc": "63.0.
|
|
45
|
+
"eslint-plugin-jsdoc": "63.0.10",
|
|
46
46
|
"typescript": "6.0.3",
|
|
47
|
-
"vitest": "4.1.
|
|
47
|
+
"vitest": "4.1.9"
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { AsyncCells, AsyncDataSource, AsyncRow,
|
|
2
|
+
* @import { AsyncCells, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -71,13 +71,15 @@ export function memorySource({ data, columns }) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
* Wraps a data source
|
|
74
|
+
* Wraps a data source, memoizing accessed cells. The row cache is a WeakMap
|
|
75
|
+
* keyed on row identity so entries are collectible once the row is unreachable,
|
|
76
|
+
* keeping a streaming scan O(1) instead of O(rows).
|
|
75
77
|
* @param {AsyncDataSource} source
|
|
76
78
|
* @returns {AsyncDataSource}
|
|
77
79
|
*/
|
|
78
80
|
export function cachedDataSource(source) {
|
|
79
|
-
/** @type {Map<string, Promise<SqlPrimitive
|
|
80
|
-
const cache = new
|
|
81
|
+
/** @type {WeakMap<object, Map<string, Promise<SqlPrimitive>>>} */
|
|
82
|
+
const cache = new WeakMap()
|
|
81
83
|
return {
|
|
82
84
|
...source,
|
|
83
85
|
scan(options) {
|
|
@@ -90,68 +92,38 @@ export function cachedDataSource(source) {
|
|
|
90
92
|
return { rows, appliedWhere, appliedLimitOffset }
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
// Adjust index when source applied offset so cache keys match original rows
|
|
94
|
-
const indexOffset = appliedLimitOffset && options.offset ? options.offset : 0
|
|
95
|
-
|
|
96
95
|
return {
|
|
97
96
|
async *rows() {
|
|
98
|
-
let index = 0
|
|
99
97
|
for await (const row of rows()) {
|
|
100
98
|
if (options.signal?.aborted) break
|
|
101
|
-
const
|
|
99
|
+
const anchor = row.resolved ?? row
|
|
100
|
+
let rowCache = cache.get(anchor)
|
|
101
|
+
if (!rowCache) {
|
|
102
|
+
rowCache = new Map()
|
|
103
|
+
cache.set(anchor, rowCache)
|
|
104
|
+
}
|
|
102
105
|
/** @type {AsyncCells} */
|
|
103
106
|
const cells = {}
|
|
104
107
|
for (const key of row.columns) {
|
|
105
108
|
const cell = row.cells[key]
|
|
106
|
-
// Wrap the cell to cache accesses
|
|
107
109
|
cells[key] = () => {
|
|
108
|
-
|
|
109
|
-
let value = cache.get(cacheKey)
|
|
110
|
+
let value = rowCache.get(key)
|
|
110
111
|
if (!value) {
|
|
111
112
|
value = cell()
|
|
112
|
-
|
|
113
|
+
rowCache.set(key, value)
|
|
113
114
|
}
|
|
114
115
|
return value
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// Preserve resolved so downstream fast paths still apply.
|
|
119
|
+
yield { columns: row.columns, cells, resolved: row.resolved }
|
|
119
120
|
}
|
|
120
121
|
},
|
|
121
122
|
appliedWhere,
|
|
122
123
|
appliedLimitOffset,
|
|
123
124
|
}
|
|
124
125
|
},
|
|
125
|
-
...source
|
|
126
|
-
|
|
127
|
-
* @param {ScanColumnOptions} options
|
|
128
|
-
* @returns {AsyncIterable<ArrayLike<SqlPrimitive>>}
|
|
129
|
-
*/
|
|
130
|
-
scanColumn(options) {
|
|
131
|
-
const inner = source.scanColumn(options)
|
|
132
|
-
const indexOffset = options.offset ?? 0
|
|
133
|
-
return (async function* () {
|
|
134
|
-
let chunkStart = 0
|
|
135
|
-
for await (const chunk of inner) {
|
|
136
|
-
if (options.signal?.aborted) break
|
|
137
|
-
/** @type {SqlPrimitive[]} */
|
|
138
|
-
const cached = new Array(chunk.length)
|
|
139
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
140
|
-
const cacheKey = `${chunkStart + i + indexOffset}:${options.column}`
|
|
141
|
-
const existing = cache.get(cacheKey)
|
|
142
|
-
if (existing) {
|
|
143
|
-
cached[i] = await existing
|
|
144
|
-
} else {
|
|
145
|
-
const value = chunk[i]
|
|
146
|
-
cache.set(cacheKey, Promise.resolve(value))
|
|
147
|
-
cached[i] = value
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
yield cached
|
|
151
|
-
chunkStart += chunk.length
|
|
152
|
-
}
|
|
153
|
-
})()
|
|
154
|
-
},
|
|
155
|
-
},
|
|
126
|
+
// scanColumn passes through from ...source unwrapped: column values are
|
|
127
|
+
// already materialized, so caching them only retained memory unboundedly.
|
|
156
128
|
}
|
|
157
129
|
}
|
|
@@ -95,6 +95,7 @@ export function executeHashAggregate(plan, context) {
|
|
|
95
95
|
}
|
|
96
96
|
allRows.push(row)
|
|
97
97
|
}
|
|
98
|
+
context.signal?.throwIfAborted()
|
|
98
99
|
|
|
99
100
|
// Group rows by GROUP BY keys.
|
|
100
101
|
// Each chunk dispatches all per-row key evaluations in parallel so
|
|
@@ -211,6 +212,7 @@ export function executeScalarAggregate(plan, context) {
|
|
|
211
212
|
}
|
|
212
213
|
group.push(row)
|
|
213
214
|
}
|
|
215
|
+
context.signal?.throwIfAborted()
|
|
214
216
|
|
|
215
217
|
const asyncRow = projectAggregateColumns(plan.columns, group, context)
|
|
216
218
|
|
|
@@ -282,9 +284,36 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
|
282
284
|
/** @type {AsyncCells} */
|
|
283
285
|
const cells = {}
|
|
284
286
|
|
|
287
|
+
// Group specs by column so each column is scanned at most once no matter how
|
|
288
|
+
// many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
|
|
289
|
+
/** @type {Map<string, ColumnAggSpec[]>} */
|
|
290
|
+
const specsByColumn = new Map()
|
|
291
|
+
for (const spec of specs) {
|
|
292
|
+
const group = specsByColumn.get(spec.column)
|
|
293
|
+
if (group) group.push(spec)
|
|
294
|
+
else specsByColumn.set(spec.column, [spec])
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Each column's single pass is computed once and shared by all its cells;
|
|
298
|
+
// a column is only scanned if one of its aggregates is actually read.
|
|
299
|
+
/** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
|
|
300
|
+
const passes = new Map()
|
|
301
|
+
/**
|
|
302
|
+
* @param {string} column
|
|
303
|
+
* @returns {Promise<Map<string, SqlPrimitive>>}
|
|
304
|
+
*/
|
|
305
|
+
function scanOnce(column) {
|
|
306
|
+
let pass = passes.get(column)
|
|
307
|
+
if (!pass) {
|
|
308
|
+
pass = scanColumnGroup({ table, specs: specsByColumn.get(column) ?? [], limit, offset, signal })
|
|
309
|
+
passes.set(column, pass)
|
|
310
|
+
}
|
|
311
|
+
return pass
|
|
312
|
+
}
|
|
313
|
+
|
|
285
314
|
for (const spec of specs) {
|
|
286
315
|
columns.push(spec.alias)
|
|
287
|
-
cells[spec.alias] = () =>
|
|
316
|
+
cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
|
|
288
317
|
}
|
|
289
318
|
|
|
290
319
|
yield { columns, cells }
|
|
@@ -316,68 +345,93 @@ function extractColumnAggSpec({ expr, alias }) {
|
|
|
316
345
|
}
|
|
317
346
|
|
|
318
347
|
/**
|
|
319
|
-
*
|
|
348
|
+
* @typedef {{
|
|
349
|
+
* spec: ColumnAggSpec,
|
|
350
|
+
* count: number,
|
|
351
|
+
* sum: number,
|
|
352
|
+
* min: SqlPrimitive,
|
|
353
|
+
* max: SqlPrimitive,
|
|
354
|
+
* seen: Set<unknown> | null,
|
|
355
|
+
* }} AggAccumulator
|
|
356
|
+
*/
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Scans a column once and computes every aggregate over it in a single pass.
|
|
360
|
+
* All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
|
|
320
361
|
*
|
|
321
362
|
* @param {Object} options
|
|
322
363
|
* @param {AsyncDataSource} options.table
|
|
323
|
-
* @param {ColumnAggSpec} options.
|
|
364
|
+
* @param {ColumnAggSpec[]} options.specs - aggregates over the same column
|
|
324
365
|
* @param {number} [options.limit]
|
|
325
366
|
* @param {number} [options.offset]
|
|
326
367
|
* @param {AbortSignal} [options.signal]
|
|
327
|
-
* @returns {Promise<SqlPrimitive
|
|
368
|
+
* @returns {Promise<Map<string, SqlPrimitive>>} alias → aggregate value
|
|
328
369
|
*/
|
|
329
|
-
async function
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return seen.size
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
if (spec.funcName === 'COUNT') {
|
|
346
|
-
let count = 0
|
|
347
|
-
for await (const chunk of values) {
|
|
348
|
-
if (signal?.aborted) return
|
|
349
|
-
for (let i = 0; i < chunk.length; i++) {
|
|
350
|
-
if (chunk[i] != null) count++
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
return count
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// SUM, AVG, MIN, MAX
|
|
357
|
-
let sum = 0
|
|
358
|
-
let count = 0
|
|
359
|
-
/** @type {SqlPrimitive} */
|
|
360
|
-
let min = null
|
|
361
|
-
/** @type {SqlPrimitive} */
|
|
362
|
-
let max = null
|
|
370
|
+
async function scanColumnGroup({ table, specs, limit, offset, signal }) {
|
|
371
|
+
const { column } = specs[0]
|
|
372
|
+
const values = table.scanColumn({ column, limit, offset, signal })
|
|
373
|
+
|
|
374
|
+
/** @type {AggAccumulator[]} */
|
|
375
|
+
const accs = specs.map(spec => /** @type {AggAccumulator} */ ({
|
|
376
|
+
spec,
|
|
377
|
+
count: 0,
|
|
378
|
+
sum: 0,
|
|
379
|
+
min: null,
|
|
380
|
+
max: null,
|
|
381
|
+
seen: spec.funcName === 'COUNT' && spec.distinct ? new Set() : null,
|
|
382
|
+
}))
|
|
363
383
|
|
|
364
384
|
for await (const chunk of values) {
|
|
365
|
-
|
|
385
|
+
signal?.throwIfAborted()
|
|
366
386
|
for (let i = 0; i < chunk.length; i++) {
|
|
367
387
|
const v = chunk[i]
|
|
368
388
|
if (v == null) continue
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
389
|
+
for (const acc of accs) {
|
|
390
|
+
switch (acc.spec.funcName) {
|
|
391
|
+
case 'COUNT':
|
|
392
|
+
if (acc.seen) acc.seen.add(keyify(v))
|
|
393
|
+
else acc.count++
|
|
394
|
+
break
|
|
395
|
+
case 'MIN':
|
|
396
|
+
if (acc.min === null || v < acc.min) acc.min = v
|
|
397
|
+
break
|
|
398
|
+
case 'MAX':
|
|
399
|
+
if (acc.max === null || v > acc.max) acc.max = v
|
|
400
|
+
break
|
|
401
|
+
default: { // SUM, AVG
|
|
402
|
+
const num = Number(v)
|
|
403
|
+
if (Number.isFinite(num)) {
|
|
404
|
+
acc.sum += num
|
|
405
|
+
acc.count++
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
375
410
|
}
|
|
376
411
|
}
|
|
412
|
+
signal?.throwIfAborted()
|
|
377
413
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
414
|
+
/** @type {Map<string, SqlPrimitive>} */
|
|
415
|
+
const result = new Map()
|
|
416
|
+
for (const acc of accs) {
|
|
417
|
+
result.set(acc.spec.alias, finalizeAgg(acc))
|
|
418
|
+
}
|
|
419
|
+
return result
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Reduces one accumulator to its final aggregate value.
|
|
424
|
+
*
|
|
425
|
+
* @param {AggAccumulator} acc
|
|
426
|
+
* @returns {SqlPrimitive}
|
|
427
|
+
*/
|
|
428
|
+
function finalizeAgg(acc) {
|
|
429
|
+
switch (acc.spec.funcName) {
|
|
430
|
+
case 'COUNT': return acc.seen ? acc.seen.size : acc.count
|
|
431
|
+
case 'SUM': return acc.count === 0 ? null : acc.sum
|
|
432
|
+
case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
|
|
433
|
+
case 'MIN': return acc.min
|
|
434
|
+
case 'MAX': return acc.max
|
|
435
|
+
default: return null
|
|
436
|
+
}
|
|
383
437
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -356,16 +356,20 @@ function executeCount(plan, context) {
|
|
|
356
356
|
numRows: 1,
|
|
357
357
|
maxRows: 1,
|
|
358
358
|
async *rows() {
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
const countPromise = (async () => {
|
|
360
|
+
signal?.throwIfAborted()
|
|
361
|
+
// Use source numRows if available
|
|
362
|
+
if (table.numRows !== undefined) return table.numRows
|
|
363
|
+
|
|
361
364
|
// Fall back to counting rows via scan
|
|
362
365
|
let count = 0
|
|
363
366
|
const { rows } = table.scan({ signal })
|
|
364
367
|
// eslint-disable-next-line no-unused-vars
|
|
365
368
|
for await (const _ of rows()) {
|
|
366
|
-
|
|
369
|
+
signal?.throwIfAborted()
|
|
367
370
|
count++
|
|
368
371
|
}
|
|
372
|
+
signal?.throwIfAborted()
|
|
369
373
|
return count
|
|
370
374
|
})()
|
|
371
375
|
|
package/src/execute/join.js
CHANGED
|
@@ -47,7 +47,7 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
47
47
|
|
|
48
48
|
let innerCount = 0
|
|
49
49
|
for await (const leftRow of left.rows()) {
|
|
50
|
-
if (context.signal?.aborted)
|
|
50
|
+
if (context.signal?.aborted) return
|
|
51
51
|
|
|
52
52
|
if (!leftCols) {
|
|
53
53
|
leftCols = leftRow.columns
|
|
@@ -80,6 +80,8 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
if (context.signal?.aborted) return
|
|
84
|
+
|
|
83
85
|
// Unmatched right rows for RIGHT/FULL joins
|
|
84
86
|
if (matchedRightRows) {
|
|
85
87
|
for (const rightRow of rightRows) {
|
|
@@ -248,7 +250,7 @@ export function executeHashJoin(plan, context) {
|
|
|
248
250
|
// Probe phase: stream left rows
|
|
249
251
|
let innerCount = 0
|
|
250
252
|
for await (const leftRow of left.rows()) {
|
|
251
|
-
if (context.signal?.aborted)
|
|
253
|
+
if (context.signal?.aborted) return
|
|
252
254
|
|
|
253
255
|
if (!leftCols) {
|
|
254
256
|
leftCols = leftRow.columns
|
|
@@ -285,6 +287,8 @@ export function executeHashJoin(plan, context) {
|
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
if (context.signal?.aborted) return
|
|
291
|
+
|
|
288
292
|
// Unmatched right rows for RIGHT/FULL joins
|
|
289
293
|
if (matchedRightRows) {
|
|
290
294
|
for (const rightRow of rightRows) {
|
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 }
|