squirreling 0.16.12 → 0.16.14
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 +2 -2
- package/src/execute/aggregates.js +12 -0
- package/src/execute/dynamicFilter.js +131 -0
- package/src/execute/execute.js +32 -1
- package/src/execute/join.js +20 -7
- package/src/execute/semiJoin.js +45 -40
- package/src/expression/evaluate.js +8 -4
- package/src/plan/plan.js +86 -163
- package/src/plan/predicates.js +29 -0
- package/src/plan/subqueries.js +285 -0
- package/src/types.d.ts +2 -0
- package/src/validation/windows.js +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.14",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@types/node": "26.6.5",
|
|
44
44
|
"@vitest/coverage-v8": "5.0.5",
|
|
45
45
|
"eslint": "10.13.0",
|
|
46
|
-
"eslint-plugin-jsdoc": "65.4.
|
|
46
|
+
"eslint-plugin-jsdoc": "65.4.1",
|
|
47
47
|
"globals": "17.13.0",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
49
|
"vitest": "5.0.5"
|
|
@@ -193,6 +193,18 @@ export function executeHashAggregate(plan, context) {
|
|
|
193
193
|
* @returns {QueryResults}
|
|
194
194
|
*/
|
|
195
195
|
export function executeScalarAggregate(plan, context) {
|
|
196
|
+
if (context.deferScans && plan.child.type === 'Scan') {
|
|
197
|
+
const child = executeScan(plan.child, context)
|
|
198
|
+
return {
|
|
199
|
+
columns: selectColumnNames(plan.columns, child.columns),
|
|
200
|
+
numRows: plan.having ? undefined : 1,
|
|
201
|
+
maxRows: 1,
|
|
202
|
+
async *rows() {
|
|
203
|
+
context.signal?.throwIfAborted()
|
|
204
|
+
yield* executeScalarAggregate(plan, { ...context, deferScans: false }).rows()
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
196
208
|
// Fast path: use scanColumn when available
|
|
197
209
|
const columnScan = tryColumnScanAggregate(plan, context)
|
|
198
210
|
if (columnScan?.rows) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ExprNode, SqlPrimitive } from '../types.js'
|
|
3
|
+
* @import { QueryPlan } from '../plan/types.js'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Bound filter memory and match the session-index pruning limit.
|
|
7
|
+
const MAX_DYNAMIC_FILTER_KEYS = 256
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bounded, per-column key sets. Once a column exceeds the cap, release it and
|
|
11
|
+
* stop collecting that column. Unsupported values disable that column too.
|
|
12
|
+
*
|
|
13
|
+
* @param {number} count
|
|
14
|
+
* @param {boolean} [hash] - preserve the hash join's serialized key equivalences
|
|
15
|
+
* @returns {{ add(values: SqlPrimitive[]): void, apply(plan: QueryPlan, keys: ExprNode[]): QueryPlan }}
|
|
16
|
+
*/
|
|
17
|
+
export function dynamicFilter(count, hash = false) {
|
|
18
|
+
/** @type {(Set<SqlPrimitive> | undefined)[]} */
|
|
19
|
+
const sets = Array.from({ length: count }, () => new Set())
|
|
20
|
+
let active = true
|
|
21
|
+
return {
|
|
22
|
+
/**
|
|
23
|
+
* @param {SqlPrimitive[]} values
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
add(values) {
|
|
27
|
+
if (!active) return
|
|
28
|
+
// Hash tuple serialization uses delimiters and serializes objects. Do
|
|
29
|
+
// not prune when those encodings can collide across column boundaries.
|
|
30
|
+
if (hash && values.some(value => typeof value === 'object' && value !== null ||
|
|
31
|
+
typeof value === 'string' && (value.includes('|') || ['[', '{', '"'].includes(value[0]) || value === 'null'))) {
|
|
32
|
+
sets.fill(undefined)
|
|
33
|
+
active = false
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
for (let i = 0; i < sets.length; i++) {
|
|
37
|
+
const set = sets[i]
|
|
38
|
+
const value = values[i]
|
|
39
|
+
if (!set || value == null) continue
|
|
40
|
+
if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value) ||
|
|
41
|
+
typeof value === 'number' && !Number.isFinite(value)) {
|
|
42
|
+
sets[i] = undefined
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
set.add(value)
|
|
46
|
+
if (hash && count > 1) {
|
|
47
|
+
if (typeof value === 'boolean') set.add(String(value))
|
|
48
|
+
if (value === 'true' || value === 'false') set.add(value === 'true')
|
|
49
|
+
}
|
|
50
|
+
if (set.size > MAX_DYNAMIC_FILTER_KEYS) sets[i] = undefined
|
|
51
|
+
}
|
|
52
|
+
active = sets.some(Boolean)
|
|
53
|
+
},
|
|
54
|
+
/**
|
|
55
|
+
* @param {QueryPlan} plan
|
|
56
|
+
* @param {ExprNode[]} keys
|
|
57
|
+
* @returns {QueryPlan}
|
|
58
|
+
*/
|
|
59
|
+
apply(plan, keys) {
|
|
60
|
+
for (let i = 0; i < keys.length; i++) {
|
|
61
|
+
const set = sets[i]
|
|
62
|
+
if (set?.size) plan = pushFilter(plan, keys[i], [...set])
|
|
63
|
+
}
|
|
64
|
+
return plan
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Only traverse operators that preserve the meaning of a physical column.
|
|
71
|
+
* Limits, aggregates, windows and projections are deliberate barriers.
|
|
72
|
+
*
|
|
73
|
+
* @param {QueryPlan} plan
|
|
74
|
+
* @param {ExprNode} key
|
|
75
|
+
* @param {SqlPrimitive[]} values
|
|
76
|
+
* @returns {QueryPlan}
|
|
77
|
+
*/
|
|
78
|
+
function pushFilter(plan, key, values) {
|
|
79
|
+
if (key?.type !== 'identifier') return plan
|
|
80
|
+
if (plan.type === 'Scan') {
|
|
81
|
+
if (key.prefix && key.prefix !== (plan.alias ?? plan.table) ||
|
|
82
|
+
plan.hints.limit !== undefined || plan.hints.offset || plan.topK) return plan
|
|
83
|
+
const position = { positionStart: key.positionStart, positionEnd: key.positionEnd }
|
|
84
|
+
/** @type {ExprNode} */
|
|
85
|
+
const filter = {
|
|
86
|
+
type: 'in valuelist', expr: key, ...position,
|
|
87
|
+
values: values.map(value => ({ type: 'literal', value, ...position })),
|
|
88
|
+
}
|
|
89
|
+
/** @type {ExprNode} */
|
|
90
|
+
const where = plan.hints.where
|
|
91
|
+
? { type: 'binary', op: 'AND', left: plan.hints.where, right: filter, ...position }
|
|
92
|
+
: filter
|
|
93
|
+
return { ...plan, hints: { ...plan.hints, where } }
|
|
94
|
+
}
|
|
95
|
+
if (plan.type === 'Filter' || plan.type === 'Sort' && !plan.topK || plan.type === 'Distinct') {
|
|
96
|
+
return { ...plan, child: pushFilter(plan.child, key, values) }
|
|
97
|
+
}
|
|
98
|
+
if (plan.type === 'HashJoin' && key.prefix) {
|
|
99
|
+
// Restrict only preserved inputs of outer joins; restricting an optional
|
|
100
|
+
// input could turn a formerly matched row into a new null-extended row.
|
|
101
|
+
const left = plan.joinType === 'INNER' || plan.joinType === 'LEFT'
|
|
102
|
+
? pushFilter(plan.left, key, values) : plan.left
|
|
103
|
+
const right = plan.joinType === 'INNER' || plan.joinType === 'RIGHT'
|
|
104
|
+
? pushFilter(plan.right, key, values) : plan.right
|
|
105
|
+
return { ...plan, left, right }
|
|
106
|
+
}
|
|
107
|
+
return plan
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Selectivity heuristic when exact filtered cardinalities are unavailable.
|
|
112
|
+
* Count conjuncts so a date plus a LIKE wins over a lone part-type predicate.
|
|
113
|
+
*
|
|
114
|
+
* @param {QueryPlan} plan
|
|
115
|
+
* @returns {number}
|
|
116
|
+
*/
|
|
117
|
+
export function filterScore(plan) {
|
|
118
|
+
if (plan.type === 'Scan') return conjuncts(plan.hints.where)
|
|
119
|
+
if (plan.type === 'Filter') return conjuncts(plan.condition) + filterScore(plan.child)
|
|
120
|
+
if (plan.type === 'HashJoin' && plan.joinType === 'INNER') return Math.max(filterScore(plan.left), filterScore(plan.right))
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {ExprNode} expr
|
|
126
|
+
* @returns {number}
|
|
127
|
+
*/
|
|
128
|
+
function conjuncts(expr) {
|
|
129
|
+
return !expr ? 0 : expr.type === 'binary' && expr.op === 'AND'
|
|
130
|
+
? conjuncts(expr.left) + conjuncts(expr.right) : 1
|
|
131
|
+
}
|
package/src/execute/execute.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { selectedRowCount } from '../backend/batch.js'
|
|
2
|
-
import { batchesToRows } from '../backend/batchAdapters.js'
|
|
2
|
+
import { batchesToRows, rowsToBatches } from '../backend/batchAdapters.js'
|
|
3
3
|
import { dataSourceColumns, memorySource } from '../backend/dataSource.js'
|
|
4
4
|
import { derivedAlias } from '../expression/alias.js'
|
|
5
5
|
import { compileBatchExpression } from '../expression/batch.js'
|
|
@@ -318,6 +318,37 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
318
318
|
const { tables, signal } = context
|
|
319
319
|
const table = validateTable({ ...plan, tables })
|
|
320
320
|
validateScan({ ...plan, tables })
|
|
321
|
+
if (context.deferScans && !existingColumnResult) {
|
|
322
|
+
const size = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
|
|
323
|
+
const columns = plan.hints.columns ?? dataSourceColumns(table)
|
|
324
|
+
/** @type {QueryResults} */
|
|
325
|
+
const deferred = {
|
|
326
|
+
columns,
|
|
327
|
+
numRows: plan.hints.where ? undefined : size,
|
|
328
|
+
maxRows: size,
|
|
329
|
+
async *rows() {
|
|
330
|
+
context.signal?.throwIfAborted()
|
|
331
|
+
yield* executeScan(plan, { ...context, deferScans: false }).rows()
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
// Preserve native batch execution above a deferred input. Legacy fallback
|
|
335
|
+
// remains at the source boundary if a source declines its column path.
|
|
336
|
+
if (table.prepareScan && table.schema || table.scanColumn && plan.hints.columns?.length === 1) {
|
|
337
|
+
deferred.batches = async function* deferredBatches() {
|
|
338
|
+
context.signal?.throwIfAborted()
|
|
339
|
+
const result = executeScan(plan, { ...context, deferScans: false })
|
|
340
|
+
if (result.batches) {
|
|
341
|
+
// Prepared sources may return extra fields or a different order.
|
|
342
|
+
// Align vectors with the metadata exposed before opening the scan.
|
|
343
|
+
const indexes = columns.map(column => result.columns.indexOf(column))
|
|
344
|
+
for await (const batch of result.batches()) {
|
|
345
|
+
yield { ...batch, columns: indexes.map(index => batch.columns[index]) }
|
|
346
|
+
}
|
|
347
|
+
} else yield* rowsToBatches(result.rows(), columns, { signal })
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return deferred
|
|
351
|
+
}
|
|
321
352
|
const { where, residual } = scanFilter(plan, dataSourceColumns(table))
|
|
322
353
|
const hints = { ...plan.hints, where }
|
|
323
354
|
if (residual) {
|
package/src/execute/join.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
2
|
import { keyify, maxBounds } from './utils.js'
|
|
3
3
|
import { executePlan } from './execute.js'
|
|
4
|
+
import { dynamicFilter, filterScore } from './dynamicFilter.js'
|
|
4
5
|
import { yieldToEventLoop } from './yield.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -222,14 +223,18 @@ export function executePositionalJoin(plan, context) {
|
|
|
222
223
|
* @returns {QueryResults}
|
|
223
224
|
*/
|
|
224
225
|
export function executeHashJoin(plan, context) {
|
|
225
|
-
const left = executePlan({ plan: plan.left, context })
|
|
226
|
-
const right = executePlan({ plan: plan.right, context })
|
|
227
|
-
//
|
|
228
|
-
//
|
|
226
|
+
const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
|
|
227
|
+
const right = executePlan({ plan: plan.right, context: { ...context, deferScans: true } })
|
|
228
|
+
// Prefer exact sizes, then predicate selectivity, then upper bounds.
|
|
229
|
+
// A filtered scan's table size is only an upper bound, not its cardinality.
|
|
229
230
|
// Swapping reorders output rows, which SQL leaves unspecified.
|
|
230
231
|
const leftSize = left.numRows ?? left.maxRows
|
|
231
232
|
const rightSize = right.numRows ?? right.maxRows
|
|
232
|
-
const swap =
|
|
233
|
+
const swap = left.numRows !== undefined && right.numRows !== undefined
|
|
234
|
+
? leftSize < rightSize
|
|
235
|
+
: filterScore(plan.left) !== filterScore(plan.right)
|
|
236
|
+
? filterScore(plan.left) > filterScore(plan.right)
|
|
237
|
+
: leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
|
|
233
238
|
return {
|
|
234
239
|
columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
|
|
235
240
|
async *rows() {
|
|
@@ -237,7 +242,7 @@ export function executeHashJoin(plan, context) {
|
|
|
237
242
|
const rightTable = plan.rightAlias
|
|
238
243
|
const { residual } = plan
|
|
239
244
|
const build = swap ? left : right
|
|
240
|
-
|
|
245
|
+
let probe = swap ? right : left
|
|
241
246
|
const buildKeys = swap ? plan.leftKeys : plan.rightKeys
|
|
242
247
|
const probeKeys = swap ? plan.rightKeys : plan.leftKeys
|
|
243
248
|
// Which sides must also emit their unmatched rows
|
|
@@ -260,6 +265,7 @@ export function executeHashJoin(plan, context) {
|
|
|
260
265
|
// otherwise rows with NULL join keys are released immediately.
|
|
261
266
|
/** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
|
|
262
267
|
const hashMap = new Map()
|
|
268
|
+
const filters = dynamicFilter(buildKeys.length, true)
|
|
263
269
|
/** @type {AsyncRow[] | undefined} */
|
|
264
270
|
const buildRows = buildOuter ? [] : undefined
|
|
265
271
|
/** @type {string[]} */
|
|
@@ -281,6 +287,7 @@ export function executeHashJoin(plan, context) {
|
|
|
281
287
|
// SQL semantics: NULL never equals anything, so a row with any NULL
|
|
282
288
|
// join key is excluded from the hash table.
|
|
283
289
|
if (keyValues.some(v => v == null)) continue
|
|
290
|
+
if (!probeOuter) filters.add(keyValues)
|
|
284
291
|
const key = keyify(...keyValues)
|
|
285
292
|
let bucket = hashMap.get(key)
|
|
286
293
|
if (!bucket) {
|
|
@@ -295,8 +302,14 @@ export function executeHashJoin(plan, context) {
|
|
|
295
302
|
/** @type {Set<AsyncRow> | undefined} */
|
|
296
303
|
const matchedBuildRows = buildOuter ? new Set() : undefined
|
|
297
304
|
|
|
305
|
+
if (!probeOuter && !hashMap.size && !buildOuter) return
|
|
306
|
+
if (!probeOuter && hashMap.size) {
|
|
307
|
+
const filtered = filters.apply(swap ? plan.right : plan.left, probeKeys)
|
|
308
|
+
probe = executePlan({ plan: filtered, context: { ...context, deferScans: true } })
|
|
309
|
+
}
|
|
310
|
+
|
|
298
311
|
// Probe phase: stream the other side
|
|
299
|
-
for await (const probeRow of probe.rows()) {
|
|
312
|
+
for await (const probeRow of !probeOuter && !hashMap.size ? [] : probe.rows()) {
|
|
300
313
|
context.signal?.throwIfAborted()
|
|
301
314
|
|
|
302
315
|
if (!probeCols) {
|
package/src/execute/semiJoin.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
2
|
import { executePlan } from './execute.js'
|
|
3
|
+
import { dynamicFilter } from './dynamicFilter.js'
|
|
3
4
|
import { sqlEquals } from './utils.js'
|
|
4
5
|
import { yieldToEventLoop } from './yield.js'
|
|
5
6
|
|
|
@@ -19,7 +20,7 @@ import { yieldToEventLoop } from './yield.js'
|
|
|
19
20
|
*/
|
|
20
21
|
export function executeSemiJoin(plan, context) {
|
|
21
22
|
if (plan.keys) return executeExistsJoin(plan, context)
|
|
22
|
-
const left = executePlan({ plan: plan.left, context })
|
|
23
|
+
const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
|
|
23
24
|
return {
|
|
24
25
|
columns: left.columns,
|
|
25
26
|
maxRows: left.maxRows,
|
|
@@ -28,29 +29,30 @@ export function executeSemiJoin(plan, context) {
|
|
|
28
29
|
const values = new Map()
|
|
29
30
|
let hasNull = false
|
|
30
31
|
let count = 0
|
|
31
|
-
let built = false
|
|
32
32
|
let rowIndex = 0
|
|
33
|
-
|
|
33
|
+
const filters = dynamicFilter(1)
|
|
34
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
35
|
+
context.signal?.throwIfAborted()
|
|
36
|
+
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
37
|
+
const value = await inner.cells[inner.columns[0]]()
|
|
38
|
+
if (value == null) hasNull = true
|
|
39
|
+
else {
|
|
40
|
+
const type = typeof value
|
|
41
|
+
let group = values.get(type)
|
|
42
|
+
if (!group) values.set(type, group = new Set())
|
|
43
|
+
group.add(value)
|
|
44
|
+
if (!plan.not) filters.add([value])
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!plan.not && !values.size || plan.not && hasNull) return
|
|
48
|
+
const probe = executePlan({
|
|
49
|
+
plan: plan.not ? plan.left : filters.apply(plan.left, [plan.expr]),
|
|
50
|
+
context: { ...context, deferScans: true },
|
|
51
|
+
})
|
|
52
|
+
for await (const row of probe.rows()) {
|
|
34
53
|
context.signal?.throwIfAborted()
|
|
35
54
|
if (++rowIndex % 4000 === 0) await yieldToEventLoop()
|
|
36
55
|
const value = await evaluateExpr({ node: plan.expr, row, rowIndex, context })
|
|
37
|
-
if (!built) {
|
|
38
|
-
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
39
|
-
if (++count % 4000 === 0) {
|
|
40
|
-
await yieldToEventLoop()
|
|
41
|
-
context.signal?.throwIfAborted()
|
|
42
|
-
}
|
|
43
|
-
const value = await inner.cells[inner.columns[0]]()
|
|
44
|
-
if (value == null) hasNull = true
|
|
45
|
-
else {
|
|
46
|
-
const type = typeof value
|
|
47
|
-
let group = values.get(type)
|
|
48
|
-
if (!group) values.set(type, group = new Set())
|
|
49
|
-
group.add(value)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
built = true
|
|
53
|
-
}
|
|
54
56
|
const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
|
|
55
57
|
if (matched !== null && matched !== plan.not) yield row
|
|
56
58
|
}
|
|
@@ -87,36 +89,39 @@ function contains(values, value) {
|
|
|
87
89
|
* @returns {QueryResults}
|
|
88
90
|
*/
|
|
89
91
|
function executeExistsJoin(plan, context) {
|
|
90
|
-
const left = executePlan({ plan: plan.left, context })
|
|
92
|
+
const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
|
|
91
93
|
return {
|
|
92
94
|
columns: left.columns,
|
|
93
95
|
maxRows: left.maxRows,
|
|
94
96
|
async *rows() {
|
|
95
97
|
/** @type {KeyTrie} */
|
|
96
98
|
const root = { groups: new Map() }
|
|
97
|
-
let built = false
|
|
98
99
|
let count = 0
|
|
99
|
-
|
|
100
|
+
const filters = dynamicFilter(plan.keys?.length ?? 0)
|
|
101
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
100
102
|
context.signal?.throwIfAborted()
|
|
101
103
|
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
let child = group.get(value)
|
|
114
|
-
if (!child) group.set(value, child = { groups: new Map() })
|
|
115
|
-
node = child
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
built = true
|
|
104
|
+
const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
|
|
105
|
+
if (values.some(value => value == null)) continue
|
|
106
|
+
if (!plan.not) filters.add(values)
|
|
107
|
+
let node = root
|
|
108
|
+
for (const value of values) {
|
|
109
|
+
const type = typeof value
|
|
110
|
+
let group = node.groups.get(type)
|
|
111
|
+
if (!group) node.groups.set(type, group = new Map())
|
|
112
|
+
let child = group.get(value)
|
|
113
|
+
if (!child) group.set(value, child = { groups: new Map() })
|
|
114
|
+
node = child
|
|
119
115
|
}
|
|
116
|
+
}
|
|
117
|
+
if (!plan.not && !root.groups.size) return
|
|
118
|
+
const probe = executePlan({
|
|
119
|
+
plan: plan.not ? plan.left : filters.apply(plan.left, plan.keys ?? []),
|
|
120
|
+
context: { ...context, deferScans: true },
|
|
121
|
+
})
|
|
122
|
+
for await (const row of probe.rows()) {
|
|
123
|
+
context.signal?.throwIfAborted()
|
|
124
|
+
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
120
125
|
const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
|
|
121
126
|
const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
|
|
122
127
|
if (matched !== plan.not) yield row
|
|
@@ -158,10 +158,14 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
158
158
|
? { ...context, outerRow: row, outerAliases: new Set(outerScope) }
|
|
159
159
|
: context
|
|
160
160
|
const gen = executeStatement({ query: node.subquery, context: subContext, outerScope }).rows()
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
try {
|
|
162
|
+
const { value } = await gen.next()
|
|
163
|
+
if (!value) return null
|
|
164
|
+
if (!(await gen.next()).done) throw new Error('Scalar subquery returned more than one row')
|
|
165
|
+
return await value.cells[value.columns[0]]()
|
|
166
|
+
} finally {
|
|
167
|
+
await gen.return(undefined)
|
|
168
|
+
}
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
// Unary operators
|
package/src/plan/plan.js
CHANGED
|
@@ -2,13 +2,16 @@ import { dataSourceColumns } from '../backend/dataSource.js'
|
|
|
2
2
|
import { derivedAlias } from '../expression/alias.js'
|
|
3
3
|
import { parseSql } from '../parse/parse.js'
|
|
4
4
|
import { findAggregate } from '../validation/aggregates.js'
|
|
5
|
+
import { findWindow } from '../validation/windows.js'
|
|
5
6
|
import { ParseError } from '../validation/parseErrors.js'
|
|
6
7
|
import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
|
|
7
8
|
import { validateNoIdentifiers, validateScan, validateTableRefs } from '../validation/tables.js'
|
|
8
9
|
import { collectColumnsFromExpr, collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, statementScope, tableFunctionColumnNames } from './columns.js'
|
|
10
|
+
import { andPredicate, collectConjuncts } from './predicates.js'
|
|
11
|
+
import { decorrelateExists, decorrelateScalars } from './subqueries.js'
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
|
-
* @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, FromSubquery, FromTable, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement
|
|
14
|
+
* @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, FromSubquery, FromTable, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
|
|
12
15
|
* @import { HashJoinNode, QueryPlan, SemiJoinNode, TableFunctionNode, WindowSpec } from './types.js'
|
|
13
16
|
*/
|
|
14
17
|
|
|
@@ -177,7 +180,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
177
180
|
const scopeColumns = collectScopeColumns({ select, cteColumns, tables })
|
|
178
181
|
/** @type {Map<string, ExprNode>} */
|
|
179
182
|
const aliases = new Map()
|
|
180
|
-
|
|
183
|
+
let columns = select.columns.map(col => {
|
|
181
184
|
if (col.type === 'derived') {
|
|
182
185
|
validateTableRefs(col.expr, scopeTables, scopeColumns)
|
|
183
186
|
const expr = resolveAliases(col.expr, aliases)
|
|
@@ -193,7 +196,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
193
196
|
}
|
|
194
197
|
return col
|
|
195
198
|
})
|
|
196
|
-
|
|
199
|
+
let orderBy = resolveOrderByAliases(select.orderBy, aliases)
|
|
197
200
|
|
|
198
201
|
// Validate qualified references in other clauses
|
|
199
202
|
validateTableRefs(select.where, scopeTables, scopeColumns)
|
|
@@ -298,9 +301,18 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
298
301
|
}
|
|
299
302
|
select = { ...select, where: residual }
|
|
300
303
|
}
|
|
304
|
+
const scalar = decorrelateScalars({
|
|
305
|
+
select, columns, orderBy, scopeAliases, scopeColumns, sourceAlias, tables, cteColumns,
|
|
306
|
+
rewriteProjection: !useGrouping && !windows.length,
|
|
307
|
+
planQuery: query => planStatement({ stmt: query, tables, ctePlans, cteColumns }),
|
|
308
|
+
})
|
|
309
|
+
columns = scalar.columns
|
|
310
|
+
select = scalar.select
|
|
311
|
+
orderBy = scalar.orderBy
|
|
312
|
+
const { scalarJoins, scalarWhere } = scalar
|
|
301
313
|
if (!select.joins.length) {
|
|
302
314
|
hints.where = select.where
|
|
303
|
-
if (!needsBuffering && !select.distinct && !semiJoins.length) {
|
|
315
|
+
if (!needsBuffering && !select.distinct && !semiJoins.length && !scalarWhere) {
|
|
304
316
|
hints.limit = select.limit
|
|
305
317
|
hints.offset = select.offset
|
|
306
318
|
}
|
|
@@ -313,7 +325,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
313
325
|
|
|
314
326
|
// Add JOINs
|
|
315
327
|
if (select.joins.length) {
|
|
316
|
-
plan = planJoin({ left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, cteColumns, perTableColumns, tables, outerScope })
|
|
328
|
+
plan = planJoin({ from: select.from, left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, cteColumns, perTableColumns, tables, outerScope })
|
|
317
329
|
}
|
|
318
330
|
|
|
319
331
|
// Whether FROM resolved to our own direct table scan
|
|
@@ -330,6 +342,17 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
330
342
|
|
|
331
343
|
for (const semiJoin of semiJoins) plan = { ...semiJoin, left: plan }
|
|
332
344
|
|
|
345
|
+
for (const joinScalar of scalarJoins) plan = joinScalar(plan)
|
|
346
|
+
if (scalarWhere) plan = { type: 'Filter', condition: scalarWhere, child: plan }
|
|
347
|
+
|
|
348
|
+
// Bare stars must only include the original query's relations.
|
|
349
|
+
columns = scalarJoins.length ? columns.flatMap(col =>
|
|
350
|
+
col.type === 'star' && !col.table
|
|
351
|
+
? [sourceAlias, ...select.joins.map(join => join.alias ?? join.table)]
|
|
352
|
+
.filter(alias => alias !== undefined).map(table => ({ ...col, table }))
|
|
353
|
+
: [col]
|
|
354
|
+
) : columns
|
|
355
|
+
|
|
333
356
|
if (useGrouping) {
|
|
334
357
|
// Aggregation path: GROUP BY or scalar aggregate
|
|
335
358
|
// HAVING is integrated into aggregate nodes for access to group context
|
|
@@ -389,7 +412,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
389
412
|
// So the order is: Sort -> Project -> Distinct -> Limit
|
|
390
413
|
|
|
391
414
|
// Fast path for SELECT * without joins
|
|
392
|
-
const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length
|
|
415
|
+
const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length && !scalarJoins.length
|
|
393
416
|
if (!isPassthrough) {
|
|
394
417
|
let projectColumns = columns
|
|
395
418
|
// When parent only needs specific columns, drop unneeded projections
|
|
@@ -401,7 +424,9 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
401
424
|
// Normalize identifiers to match the child's cell-key layout, so the
|
|
402
425
|
// Project executor can look up cells by exact name instead of relying
|
|
403
426
|
// on the evaluator's suffix-search fallback.
|
|
404
|
-
const sourceColumns = inferSelectSourceColumns({ select, cteColumns, tables })
|
|
427
|
+
const sourceColumns = inferSelectSourceColumns({ select, cteColumns, tables }).map(name =>
|
|
428
|
+
scalarJoins.length && !name.includes('.') ? `${sourceAlias}.${name}` : name
|
|
429
|
+
)
|
|
405
430
|
projectColumns = projectColumns.map(col =>
|
|
406
431
|
col.type === 'derived'
|
|
407
432
|
? { ...col, expr: normalizeIdentifiers(col.expr, sourceColumns) }
|
|
@@ -414,7 +439,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
414
439
|
plan = { type: 'Distinct', child: plan }
|
|
415
440
|
}
|
|
416
441
|
|
|
417
|
-
if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length) && (select.limit !== undefined || select.offset)) {
|
|
442
|
+
if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length && !scalarWhere) && (select.limit !== undefined || select.offset)) {
|
|
418
443
|
if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
|
|
419
444
|
plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
|
|
420
445
|
}
|
|
@@ -545,6 +570,7 @@ function planTableFunction(from) {
|
|
|
545
570
|
|
|
546
571
|
/**
|
|
547
572
|
* @param {object} options
|
|
573
|
+
* @param {FromTable | FromSubquery | FromFunction | undefined} options.from
|
|
548
574
|
* @param {QueryPlan} options.left - the left side of the join (FROM or previous joins)
|
|
549
575
|
* @param {JoinClause[]} options.joins - array of join clauses
|
|
550
576
|
* @param {string} options.leftTable - name/alias of the left table
|
|
@@ -555,9 +581,10 @@ function planTableFunction(from) {
|
|
|
555
581
|
* @param {string[]} [options.outerScope] - aliases from an outer query (for correlated subqueries)
|
|
556
582
|
* @returns {QueryPlan}
|
|
557
583
|
*/
|
|
558
|
-
function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumns, tables, outerScope }) {
|
|
584
|
+
function planJoin({ from, left, joins, leftTable, ctePlans, cteColumns, perTableColumns, tables, outerScope }) {
|
|
559
585
|
let plan = left
|
|
560
586
|
let currentLeftTable = leftTable
|
|
587
|
+
const sources = [from]
|
|
561
588
|
|
|
562
589
|
// Running scope for lateral UNNEST arg validation — excludes the current
|
|
563
590
|
// join's own alias (self-reference) and later joins (forward reference).
|
|
@@ -569,6 +596,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
|
|
|
569
596
|
|
|
570
597
|
for (const join of joins) {
|
|
571
598
|
const rightTable = join.alias ?? join.table
|
|
599
|
+
sources.push(join.fromFunction ?? joinSource(join))
|
|
572
600
|
|
|
573
601
|
// LATERAL table function: right side references left-side columns.
|
|
574
602
|
if (join.fromFunction) {
|
|
@@ -641,7 +669,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
|
|
|
641
669
|
// `USING (cols)` desugars to an equi-condition `left.col = right.col` per
|
|
642
670
|
// column, which routes through the hash-join path like any other ON.
|
|
643
671
|
const condition = join.on ?? (join.using && buildUsingCondition(join.using, join))
|
|
644
|
-
const keys = condition && extractEquiKeys({ condition,
|
|
672
|
+
const keys = condition && extractEquiKeys({ condition, owner: joinColumnOwner(sources, tables, cteColumns), using: !!join.using })
|
|
645
673
|
if (keys) {
|
|
646
674
|
/** @type {HashJoinNode} */
|
|
647
675
|
const hashJoin = {
|
|
@@ -853,11 +881,11 @@ function buildUsingCondition(using, pos) {
|
|
|
853
881
|
*
|
|
854
882
|
* @param {object} options
|
|
855
883
|
* @param {ExprNode} options.condition
|
|
856
|
-
* @param {string} options.
|
|
857
|
-
* @param {
|
|
884
|
+
* @param {(expr: ExprNode) => string} options.owner
|
|
885
|
+
* @param {boolean} options.using
|
|
858
886
|
* @returns {{ leftKeys: ExprNode[], rightKeys: ExprNode[], residual?: ExprNode } | undefined}
|
|
859
887
|
*/
|
|
860
|
-
function extractEquiKeys({ condition,
|
|
888
|
+
function extractEquiKeys({ condition, owner, using }) {
|
|
861
889
|
/** @type {ExprNode[]} */
|
|
862
890
|
const conjuncts = []
|
|
863
891
|
collectConjuncts(condition, conjuncts)
|
|
@@ -868,7 +896,7 @@ function extractEquiKeys({ condition, leftTable, rightTable }) {
|
|
|
868
896
|
/** @type {ExprNode[]} */
|
|
869
897
|
const residuals = []
|
|
870
898
|
for (const conjunct of conjuncts) {
|
|
871
|
-
const eq = classifyEquiConjunct(conjunct,
|
|
899
|
+
const eq = classifyEquiConjunct(conjunct, owner, using)
|
|
872
900
|
if (eq) {
|
|
873
901
|
leftKeys.push(eq.leftKey)
|
|
874
902
|
rightKeys.push(eq.rightKey)
|
|
@@ -887,23 +915,6 @@ function extractEquiKeys({ condition, leftTable, rightTable }) {
|
|
|
887
915
|
return residual ? { leftKeys, rightKeys, residual } : { leftKeys, rightKeys }
|
|
888
916
|
}
|
|
889
917
|
|
|
890
|
-
/**
|
|
891
|
-
* Walks an ON expression, flattening top-level AND conjuncts. Non-AND nodes
|
|
892
|
-
* are pushed verbatim. Used to expose individual predicates so equi-keys can
|
|
893
|
-
* be lifted out of an AND chain.
|
|
894
|
-
*
|
|
895
|
-
* @param {ExprNode} node
|
|
896
|
-
* @param {ExprNode[]} out
|
|
897
|
-
*/
|
|
898
|
-
function collectConjuncts(node, out) {
|
|
899
|
-
if (node.type === 'binary' && node.op === 'AND') {
|
|
900
|
-
collectConjuncts(node.left, out)
|
|
901
|
-
collectConjuncts(node.right, out)
|
|
902
|
-
return
|
|
903
|
-
}
|
|
904
|
-
out.push(node)
|
|
905
|
-
}
|
|
906
|
-
|
|
907
918
|
/**
|
|
908
919
|
* Push local ON and WHERE conjuncts according to which side is preserved.
|
|
909
920
|
* Only recurse through this SELECT's join chain, never through shared CTEs
|
|
@@ -926,27 +937,11 @@ function pushJoinPredicates(plan, where, select, tables, cteColumns, inherited =
|
|
|
926
937
|
const rightSource = joinSource(last)
|
|
927
938
|
const sources = [...leftSources, rightSource]
|
|
928
939
|
const aliases = sources.map(source => fromAlias(source))
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
: source?.type === 'table'
|
|
932
|
-
? cteColumns?.get(source.table?.toLowerCase()) ?? dataSourceColumns(tables[source.table])
|
|
933
|
-
: [])
|
|
940
|
+
const owner = joinColumnOwner(sources, tables, cteColumns)
|
|
941
|
+
const columns = sources.map(source => sourceColumns(source, tables, cteColumns))
|
|
934
942
|
if (new Set(aliases).size !== aliases.length ||
|
|
935
943
|
columns.some(names => aliases.some(alias => names.includes(alias)))) return where
|
|
936
944
|
|
|
937
|
-
/**
|
|
938
|
-
* @param {ExprNode} expr
|
|
939
|
-
* @returns {'left' | 'right' | 'literal' | 'unsafe'}
|
|
940
|
-
*/
|
|
941
|
-
function owner(expr) {
|
|
942
|
-
if (expr.type === 'literal') return 'literal'
|
|
943
|
-
if (expr.type !== 'identifier') return 'unsafe'
|
|
944
|
-
const matches = columns.map((names, i) => names.includes(expr.name) ? i : -1).filter(i => i >= 0)
|
|
945
|
-
const index = expr.prefix ? aliases.indexOf(expr.prefix) : matches.length === 1 ? matches[0] : -1
|
|
946
|
-
if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
|
|
947
|
-
return index < leftSources.length ? 'left' : 'right'
|
|
948
|
-
}
|
|
949
|
-
|
|
950
945
|
// Do not change the interpretation of ambiguous or misoriented hash keys.
|
|
951
946
|
if (plan.type === 'HashJoin' && (plan.leftKeys.some(key => owner(key) !== 'left') ||
|
|
952
947
|
plan.rightKeys.some(key => owner(key) !== 'right'))) return where
|
|
@@ -1122,39 +1117,59 @@ function rejectsNulls(expr, isMissing) {
|
|
|
1122
1117
|
}
|
|
1123
1118
|
|
|
1124
1119
|
/**
|
|
1125
|
-
* @param {
|
|
1126
|
-
* @param {
|
|
1127
|
-
* @
|
|
1120
|
+
* @param {FromTable | FromSubquery | FromFunction | undefined} source
|
|
1121
|
+
* @param {Record<string, AsyncDataSource>} [tables]
|
|
1122
|
+
* @param {Map<string, string[]>} [cteColumns]
|
|
1123
|
+
* @returns {string[]}
|
|
1124
|
+
*/
|
|
1125
|
+
function sourceColumns(source, tables, cteColumns) {
|
|
1126
|
+
if (source?.type === 'subquery') return inferStatementColumns({ stmt: source.query, tables, cteColumns })
|
|
1127
|
+
if (source?.type === 'function') return tableFunctionColumnNames(source)
|
|
1128
|
+
if (source?.type === 'table') return cteColumns?.get(source.table.toLowerCase()) ?? (tables?.[source.table] ? dataSourceColumns(tables[source.table]) : [])
|
|
1129
|
+
return []
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Resolve references against every visible source, not the synthetic alias
|
|
1134
|
+
* used to merge rows from earlier joins. Unknown or ambiguous references
|
|
1135
|
+
* remain residual predicates rather than becoming incorrectly oriented keys.
|
|
1136
|
+
*
|
|
1137
|
+
* @param {(FromTable | FromSubquery | FromFunction | undefined)[]} sources
|
|
1138
|
+
* @param {Record<string, AsyncDataSource>} [tables]
|
|
1139
|
+
* @param {Map<string, string[]>} [cteColumns]
|
|
1140
|
+
* @returns {(expr: ExprNode) => 'left' | 'right' | 'literal' | 'unsafe'}
|
|
1128
1141
|
*/
|
|
1129
|
-
function
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1142
|
+
function joinColumnOwner(sources, tables, cteColumns) {
|
|
1143
|
+
const aliases = sources.map(source => fromAlias(source))
|
|
1144
|
+
const columns = sources.map(source => sourceColumns(source, tables, cteColumns))
|
|
1145
|
+
const unsafe = new Set(aliases).size !== aliases.length ||
|
|
1146
|
+
columns.some(names => aliases.some(alias => alias !== undefined && names.includes(alias)))
|
|
1147
|
+
return function owner(expr) {
|
|
1148
|
+
if (expr.type === 'literal') return 'literal'
|
|
1149
|
+
if (unsafe || expr.type !== 'identifier') return 'unsafe'
|
|
1150
|
+
const matches = columns.map((names, i) => names.includes(expr.name) ? i : -1).filter(i => i >= 0)
|
|
1151
|
+
const index = expr.prefix ? aliases.indexOf(expr.prefix) : matches.length === 1 ? matches[0] : -1
|
|
1152
|
+
if (index < 0) return 'unsafe'
|
|
1153
|
+
if (columns[index].length && !columns[index].includes(expr.name)) return 'unsafe'
|
|
1154
|
+
return index === sources.length - 1 ? 'right' : 'left'
|
|
1155
|
+
}
|
|
1133
1156
|
}
|
|
1134
1157
|
|
|
1135
1158
|
/**
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
* Returns undefined when the conjunct is not a `<identifier> = <identifier>`
|
|
1139
|
-
* predicate. When the prefixes don't unambiguously identify a side, falls
|
|
1140
|
-
* through to the original orientation — matches the prior single-equi
|
|
1141
|
-
* behavior so unprefixed columns still produce a hash join.
|
|
1159
|
+
* Orient only equalities whose identifiers belong to opposite inputs.
|
|
1160
|
+
* USING explicitly binds its unqualified identifiers to their respective sides.
|
|
1142
1161
|
*
|
|
1143
1162
|
* @param {ExprNode} conjunct
|
|
1144
|
-
* @param {string}
|
|
1145
|
-
* @param {
|
|
1163
|
+
* @param {(expr: ExprNode) => string} owner
|
|
1164
|
+
* @param {boolean} using
|
|
1146
1165
|
* @returns {{ leftKey: ExprNode, rightKey: ExprNode } | undefined}
|
|
1147
1166
|
*/
|
|
1148
|
-
function classifyEquiConjunct(conjunct,
|
|
1167
|
+
function classifyEquiConjunct(conjunct, owner, using) {
|
|
1149
1168
|
if (conjunct.type !== 'binary' || conjunct.op !== '=') return undefined
|
|
1150
1169
|
const { left, right } = conjunct
|
|
1151
1170
|
if (left.type !== 'identifier' || right.type !== 'identifier') return undefined
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
if (leftRefsRight && rightRefsLeft) {
|
|
1155
|
-
return { leftKey: right, rightKey: left }
|
|
1156
|
-
}
|
|
1157
|
-
return { leftKey: left, rightKey: right }
|
|
1171
|
+
if (using || owner(left) === 'left' && owner(right) === 'right') return { leftKey: left, rightKey: right }
|
|
1172
|
+
if (owner(left) === 'right' && owner(right) === 'left') return { leftKey: right, rightKey: left }
|
|
1158
1173
|
}
|
|
1159
1174
|
|
|
1160
1175
|
/**
|
|
@@ -1297,47 +1312,6 @@ function expectNoWindowFunction(expr, clause) {
|
|
|
1297
1312
|
}
|
|
1298
1313
|
}
|
|
1299
1314
|
|
|
1300
|
-
/**
|
|
1301
|
-
* @param {ExprNode | undefined} expr
|
|
1302
|
-
* @returns {WindowFunctionNode | undefined}
|
|
1303
|
-
*/
|
|
1304
|
-
function findWindow(expr) {
|
|
1305
|
-
if (!expr) return undefined
|
|
1306
|
-
if (expr.type === 'window') return expr
|
|
1307
|
-
if (expr.type === 'binary') return findWindow(expr.left) || findWindow(expr.right)
|
|
1308
|
-
if (expr.type === 'unary') return findWindow(expr.argument)
|
|
1309
|
-
if (expr.type === 'function') {
|
|
1310
|
-
for (const arg of expr.args) {
|
|
1311
|
-
const found = findWindow(arg)
|
|
1312
|
-
if (found) return found
|
|
1313
|
-
}
|
|
1314
|
-
return undefined
|
|
1315
|
-
}
|
|
1316
|
-
if (expr.type === 'cast') return findWindow(expr.expr)
|
|
1317
|
-
if (expr.type === 'subscript') return findWindow(expr.expr) || findWindow(expr.index)
|
|
1318
|
-
if (expr.type === 'in valuelist') {
|
|
1319
|
-
const found = findWindow(expr.expr)
|
|
1320
|
-
if (found) return found
|
|
1321
|
-
for (const val of expr.values) {
|
|
1322
|
-
const f = findWindow(val)
|
|
1323
|
-
if (f) return f
|
|
1324
|
-
}
|
|
1325
|
-
return undefined
|
|
1326
|
-
}
|
|
1327
|
-
if (expr.type === 'case') {
|
|
1328
|
-
if (expr.caseExpr) {
|
|
1329
|
-
const f = findWindow(expr.caseExpr)
|
|
1330
|
-
if (f) return f
|
|
1331
|
-
}
|
|
1332
|
-
for (const w of expr.whenClauses) {
|
|
1333
|
-
const f = findWindow(w.condition) || findWindow(w.result)
|
|
1334
|
-
if (f) return f
|
|
1335
|
-
}
|
|
1336
|
-
if (expr.elseResult) return findWindow(expr.elseResult)
|
|
1337
|
-
}
|
|
1338
|
-
return undefined
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
1315
|
/**
|
|
1342
1316
|
* Checks if every SELECT column is a plain COUNT(*).
|
|
1343
1317
|
*
|
|
@@ -1356,54 +1330,3 @@ function isAllCountStar(columns) {
|
|
|
1356
1330
|
!col.expr.filter
|
|
1357
1331
|
)
|
|
1358
1332
|
}
|
|
1359
|
-
|
|
1360
|
-
/**
|
|
1361
|
-
* Pulls plain equality correlations out of a row-preserving EXISTS query.
|
|
1362
|
-
* Limits and aggregation must stay on the per-outer-row execution path.
|
|
1363
|
-
*
|
|
1364
|
-
* @param {Statement} query
|
|
1365
|
-
* @param {string[]} outerScope
|
|
1366
|
-
* @param {Record<string, AsyncDataSource>} [tables]
|
|
1367
|
-
* @param {Map<string, string[]>} [cteColumns]
|
|
1368
|
-
* @returns {{ query: SelectStatement, left: IdentifierNode[] } | undefined}
|
|
1369
|
-
*/
|
|
1370
|
-
function decorrelateExists(query, outerScope, tables, cteColumns) {
|
|
1371
|
-
if (query.type !== 'select' || query.limit !== undefined || query.offset ||
|
|
1372
|
-
query.groupBy.length || query.having ||
|
|
1373
|
-
query.columns.some(col => col.type === 'derived' && (findAggregate(col.expr) || col.expr.type === 'window'))) return
|
|
1374
|
-
const scope = statementScope(query) ?? []
|
|
1375
|
-
/** @type {IdentifierNode[]} */
|
|
1376
|
-
const left = []
|
|
1377
|
-
/** @type {DerivedColumn[]} */
|
|
1378
|
-
const columns = []
|
|
1379
|
-
/** @type {ExprNode[]} */
|
|
1380
|
-
const conjuncts = []
|
|
1381
|
-
if (query.where) collectConjuncts(query.where, conjuncts)
|
|
1382
|
-
/** @type {ExprNode | undefined} */
|
|
1383
|
-
let where
|
|
1384
|
-
for (const term of conjuncts) {
|
|
1385
|
-
const previousKeys = columns.length
|
|
1386
|
-
if (term.type === 'binary' && term.op === '=' && term.left.type === 'identifier' && term.right.type === 'identifier') {
|
|
1387
|
-
for (const [inner, outer] of [[term.left, term.right], [term.right, term.left]]) {
|
|
1388
|
-
if (inner.prefix && scope.includes(inner.prefix) && outer.prefix &&
|
|
1389
|
-
outerScope.includes(outer.prefix) && !scope.includes(outer.prefix)) {
|
|
1390
|
-
left.push(outer)
|
|
1391
|
-
columns.push({ type: 'derived', expr: inner, alias: `__exists_key_${columns.length}`, positionStart: inner.positionStart, positionEnd: inner.positionEnd })
|
|
1392
|
-
break
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
if (columns.length > previousKeys) continue
|
|
1396
|
-
}
|
|
1397
|
-
where = andPredicate(where, term)
|
|
1398
|
-
}
|
|
1399
|
-
if (!left.length) return
|
|
1400
|
-
// Check every remaining clause, including nested subqueries and projections,
|
|
1401
|
-
// before discarding the projection and ordering that EXISTS does not read.
|
|
1402
|
-
/** @type {IdentifierNode[]} */
|
|
1403
|
-
const remaining = []
|
|
1404
|
-
collectColumnsFromExpr({ type: 'subquery', subquery: { ...query, where }, positionStart: query.positionStart, positionEnd: query.positionEnd }, remaining, undefined, {
|
|
1405
|
-
tables, cteColumns, outerAliases: new Set(outerScope),
|
|
1406
|
-
})
|
|
1407
|
-
if (remaining.length) return
|
|
1408
|
-
return { query: { ...query, columns, where, distinct: false, orderBy: [] }, left }
|
|
1409
|
-
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @import { ExprNode } from '../types.js' */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Walks an ON expression, flattening top-level AND conjuncts. Non-AND nodes
|
|
5
|
+
* are pushed verbatim. Used to expose individual predicates so equi-keys can
|
|
6
|
+
* be lifted out of an AND chain.
|
|
7
|
+
*
|
|
8
|
+
* @param {ExprNode} node
|
|
9
|
+
* @param {ExprNode[]} out
|
|
10
|
+
*/
|
|
11
|
+
export function collectConjuncts(node, out) {
|
|
12
|
+
if (node.type === 'binary' && node.op === 'AND') {
|
|
13
|
+
collectConjuncts(node.left, out)
|
|
14
|
+
collectConjuncts(node.right, out)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
out.push(node)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {ExprNode | undefined} left
|
|
22
|
+
* @param {ExprNode} right
|
|
23
|
+
* @returns {ExprNode}
|
|
24
|
+
*/
|
|
25
|
+
export function andPredicate(left, right) {
|
|
26
|
+
return left
|
|
27
|
+
? { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
|
|
28
|
+
: right
|
|
29
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { derivedAlias } from '../expression/alias.js'
|
|
2
|
+
import { findAggregate } from '../validation/aggregates.js'
|
|
3
|
+
import { findWindow } from '../validation/windows.js'
|
|
4
|
+
import { collectColumnsFromExpr, statementScope } from './columns.js'
|
|
5
|
+
import { andPredicate, collectConjuncts } from './predicates.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @import { AsyncDataSource, DerivedColumn, ExprNode, IdentifierNode, OrderByItem, SelectColumn, SelectStatement, Statement } from '../types.js'
|
|
9
|
+
* @import { QueryPlan } from './types.js'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Replaces eligible scalar aggregates with grouped LEFT JOINs and exact
|
|
14
|
+
* empty-group values. Returns correlated WHERE terms for filtering after joins.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} options
|
|
17
|
+
* @param {SelectStatement} options.select
|
|
18
|
+
* @param {SelectColumn[]} options.columns
|
|
19
|
+
* @param {OrderByItem[]} options.orderBy
|
|
20
|
+
* @param {string[]} options.scopeAliases
|
|
21
|
+
* @param {Set<string>} options.scopeColumns
|
|
22
|
+
* @param {string | undefined} options.sourceAlias
|
|
23
|
+
* @param {boolean} options.rewriteProjection
|
|
24
|
+
* @param {(query: SelectStatement) => QueryPlan} options.planQuery
|
|
25
|
+
* @param {Record<string, AsyncDataSource>} [options.tables]
|
|
26
|
+
* @param {Map<string, string[]>} [options.cteColumns]
|
|
27
|
+
* @returns {{ select: SelectStatement, columns: SelectColumn[], orderBy: OrderByItem[], scalarJoins: ((left: QueryPlan) => QueryPlan)[], scalarWhere: ExprNode | undefined }}
|
|
28
|
+
*/
|
|
29
|
+
export function decorrelateScalars({ select, columns, orderBy, scopeAliases, scopeColumns, sourceAlias, rewriteProjection, planQuery, tables, cteColumns }) {
|
|
30
|
+
columns = [...columns]
|
|
31
|
+
// Keep original expressions for source column demand; synthetic scalar cells
|
|
32
|
+
// are computed above the scan and must never appear in pushdown hints.
|
|
33
|
+
/** @type {((left: QueryPlan) => QueryPlan)[]} */
|
|
34
|
+
const scalarJoins = []
|
|
35
|
+
/**
|
|
36
|
+
* @param {ExprNode} expr
|
|
37
|
+
* @returns {ExprNode}
|
|
38
|
+
*/
|
|
39
|
+
function rewriteScalar(expr) {
|
|
40
|
+
if (expr.type !== 'subquery' || select.limit === 0) return expr
|
|
41
|
+
const query = expr.subquery
|
|
42
|
+
if (query.type !== 'select' || query.columns.length !== 1) return expr
|
|
43
|
+
const column = query.columns[0]
|
|
44
|
+
if (column.type !== 'derived' || !findAggregate(column.expr) || findWindow(column.expr)) return expr
|
|
45
|
+
// Reuse EXISTS' residual-reference check without discarding the aggregate.
|
|
46
|
+
const keys = decorrelateExists(query, scopeAliases, tables, cteColumns, true)
|
|
47
|
+
if (!keys) return expr
|
|
48
|
+
const inner = keys.query.columns.map(col => col.type === 'derived' ? col.expr : column.expr)
|
|
49
|
+
// Hash equality is only equivalent to scalar equality for matching known
|
|
50
|
+
// primitive types. Unknown schemas and coercive comparisons stay per-row.
|
|
51
|
+
if (inner.some((key, i) => {
|
|
52
|
+
if (key.type !== 'identifier') return true
|
|
53
|
+
const type = correlationType(query, key, tables, cteColumns)
|
|
54
|
+
return !type || type !== correlationType(select, keys.left[i], tables, cteColumns)
|
|
55
|
+
})) return expr
|
|
56
|
+
let name = `__scalar_${scalarJoins.length}`
|
|
57
|
+
while ([...scopeColumns, ...scopeAliases].some(col => col === name || col.startsWith(name + '_'))) name += '_'
|
|
58
|
+
const position = { positionStart: expr.positionStart, positionEnd: expr.positionEnd }
|
|
59
|
+
const valueName = name + '_value'
|
|
60
|
+
const keyName = name + '_key'
|
|
61
|
+
const emptyAlias = name + '_empty'
|
|
62
|
+
const innerKey = scalarCorrelationKey(inner)
|
|
63
|
+
const outerKey = scalarCorrelationKey(keys.left)
|
|
64
|
+
/** @type {IdentifierNode} */
|
|
65
|
+
const joinedKey = { ...position, type: 'identifier', prefix: name, name: keyName }
|
|
66
|
+
/** @type {SelectStatement} */
|
|
67
|
+
const grouped = {
|
|
68
|
+
...keys.query,
|
|
69
|
+
groupBy: [innerKey],
|
|
70
|
+
// Evaluate the original projection before introducing synthetic aliases.
|
|
71
|
+
columns: [
|
|
72
|
+
{ ...column, alias: valueName },
|
|
73
|
+
{ ...position, type: 'derived', expr: innerKey, alias: keyName },
|
|
74
|
+
],
|
|
75
|
+
}
|
|
76
|
+
const right = planQuery(grouped)
|
|
77
|
+
const scope = statementScope(query) ?? []
|
|
78
|
+
// A scalar aggregate over zero rows computes the exact empty-group value,
|
|
79
|
+
// including COUNT arithmetic and aggregate expressions with lazy branches.
|
|
80
|
+
/** @type {QueryPlan} */
|
|
81
|
+
const empty = {
|
|
82
|
+
type: 'Subquery', scope,
|
|
83
|
+
child: {
|
|
84
|
+
type: 'ScalarAggregate', columns: [{ ...column, alias: valueName }],
|
|
85
|
+
child: { type: 'Limit', limit: 0, child: { type: 'SingleRow' } },
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
/** @type {ExprNode | undefined} */
|
|
89
|
+
let residual
|
|
90
|
+
for (const key of keys.left) {
|
|
91
|
+
// Map treats NaN as equal to itself; SQL equality does not.
|
|
92
|
+
residual = andPredicate(residual, { ...position, type: 'binary', op: '=', left: key, right: key })
|
|
93
|
+
}
|
|
94
|
+
scalarJoins.push(left => ({
|
|
95
|
+
type: 'HashJoin', joinType: 'LEFT', leftAlias: sourceAlias ?? '', rightAlias: name,
|
|
96
|
+
leftKeys: [outerKey], rightKeys: [{ ...joinedKey, prefix: undefined }], residual,
|
|
97
|
+
right: { type: 'Subquery', child: right, scope },
|
|
98
|
+
left: {
|
|
99
|
+
type: 'NestedLoopJoin', joinType: 'INNER', leftAlias: sourceAlias ?? '', rightAlias: emptyAlias, right: empty,
|
|
100
|
+
condition: { ...position, type: 'literal', value: true }, left,
|
|
101
|
+
},
|
|
102
|
+
}))
|
|
103
|
+
return {
|
|
104
|
+
...position, type: 'case',
|
|
105
|
+
whenClauses: [{
|
|
106
|
+
...position,
|
|
107
|
+
condition: { ...position, type: 'unary', op: 'IS NULL', argument: joinedKey },
|
|
108
|
+
result: { ...position, type: 'identifier', prefix: emptyAlias, name: valueName },
|
|
109
|
+
}],
|
|
110
|
+
elseResult: { ...position, type: 'identifier', prefix: name, name: valueName },
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Outer aggregates need group-aware placement, so retain their existing path.
|
|
114
|
+
if (rewriteProjection) {
|
|
115
|
+
for (let i = 0; i < columns.length; i++) {
|
|
116
|
+
const col = columns[i]
|
|
117
|
+
if (col.type === 'derived') {
|
|
118
|
+
const before = scalarJoins.length
|
|
119
|
+
const expr = mapScalarExpressions(col.expr, rewriteScalar)
|
|
120
|
+
if (scalarJoins.length > before) columns[i] = { ...col, alias: col.alias ?? derivedAlias(col.expr), expr }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
orderBy = orderBy.map(term => ({ ...term, expr: mapScalarExpressions(term.expr, rewriteScalar) }))
|
|
124
|
+
}
|
|
125
|
+
/** @type {ExprNode | undefined} */
|
|
126
|
+
let scalarWhere
|
|
127
|
+
if (select.where) {
|
|
128
|
+
/** @type {ExprNode[]} */
|
|
129
|
+
const terms = []
|
|
130
|
+
collectConjuncts(select.where, terms)
|
|
131
|
+
/** @type {ExprNode | undefined} */
|
|
132
|
+
let scanWhere
|
|
133
|
+
for (const term of terms) {
|
|
134
|
+
const before = scalarJoins.length
|
|
135
|
+
const rewritten = mapScalarExpressions(term, rewriteScalar)
|
|
136
|
+
if (scalarJoins.length > before) scalarWhere = andPredicate(scalarWhere, rewritten)
|
|
137
|
+
else scanWhere = andPredicate(scanWhere, term)
|
|
138
|
+
}
|
|
139
|
+
select = { ...select, where: scanWhere }
|
|
140
|
+
}
|
|
141
|
+
return { select, columns, orderBy, scalarJoins, scalarWhere }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pulls plain equality correlations out of EXISTS or scalar aggregate queries.
|
|
146
|
+
* Limits, HAVING, and existing grouping stay on the per-outer-row path.
|
|
147
|
+
*
|
|
148
|
+
* @param {Statement} query
|
|
149
|
+
* @param {string[]} outerScope
|
|
150
|
+
* @param {Record<string, AsyncDataSource>} [tables]
|
|
151
|
+
* @param {Map<string, string[]>} [cteColumns]
|
|
152
|
+
* @param {boolean} [aggregate]
|
|
153
|
+
* @returns {{ query: SelectStatement, left: IdentifierNode[] } | undefined}
|
|
154
|
+
*/
|
|
155
|
+
export function decorrelateExists(query, outerScope, tables, cteColumns, aggregate = false) {
|
|
156
|
+
if (query.type !== 'select' || query.limit !== undefined || query.offset ||
|
|
157
|
+
query.groupBy.length || query.having ||
|
|
158
|
+
!aggregate && query.columns.some(col => col.type === 'derived' && (findAggregate(col.expr) || col.expr.type === 'window'))) return
|
|
159
|
+
const scope = statementScope(query) ?? []
|
|
160
|
+
/** @type {IdentifierNode[]} */
|
|
161
|
+
const left = []
|
|
162
|
+
/** @type {DerivedColumn[]} */
|
|
163
|
+
const columns = []
|
|
164
|
+
/** @type {ExprNode[]} */
|
|
165
|
+
const conjuncts = []
|
|
166
|
+
if (query.where) collectConjuncts(query.where, conjuncts)
|
|
167
|
+
/** @type {ExprNode | undefined} */
|
|
168
|
+
let where
|
|
169
|
+
for (const term of conjuncts) {
|
|
170
|
+
const previousKeys = columns.length
|
|
171
|
+
if (term.type === 'binary' && term.op === '=' && term.left.type === 'identifier' && term.right.type === 'identifier') {
|
|
172
|
+
for (const [inner, outer] of [[term.left, term.right], [term.right, term.left]]) {
|
|
173
|
+
if (inner.prefix && scope.includes(inner.prefix) && outer.prefix &&
|
|
174
|
+
outerScope.includes(outer.prefix) && !scope.includes(outer.prefix)) {
|
|
175
|
+
left.push(outer)
|
|
176
|
+
columns.push({ type: 'derived', expr: inner, alias: `__exists_key_${columns.length}`, positionStart: inner.positionStart, positionEnd: inner.positionEnd })
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (columns.length > previousKeys) continue
|
|
181
|
+
}
|
|
182
|
+
where = andPredicate(where, term)
|
|
183
|
+
}
|
|
184
|
+
if (!left.length) return
|
|
185
|
+
// Check every remaining clause, including nested subqueries and projections,
|
|
186
|
+
// before discarding the projection and ordering that EXISTS does not read.
|
|
187
|
+
/** @type {IdentifierNode[]} */
|
|
188
|
+
const remaining = []
|
|
189
|
+
collectColumnsFromExpr({ type: 'subquery', subquery: { ...query, where }, positionStart: query.positionStart, positionEnd: query.positionEnd }, remaining, undefined, {
|
|
190
|
+
tables, cteColumns, outerAliases: new Set(outerScope),
|
|
191
|
+
})
|
|
192
|
+
if (remaining.length) return
|
|
193
|
+
return { query: { ...query, columns, where, distinct: false, orderBy: [] }, left }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Rewrites scalar expressions without entering a nested lexical query scope.
|
|
198
|
+
* @param {ExprNode} node
|
|
199
|
+
* @param {(node: ExprNode) => ExprNode} rewrite
|
|
200
|
+
* @returns {ExprNode}
|
|
201
|
+
*/
|
|
202
|
+
function mapScalarExpressions(node, rewrite) {
|
|
203
|
+
if (!node) return node
|
|
204
|
+
if (node.type === 'unary') {
|
|
205
|
+
return { ...node, argument: mapScalarExpressions(node.argument, rewrite) }
|
|
206
|
+
}
|
|
207
|
+
if (node.type === 'binary') {
|
|
208
|
+
return { ...node, left: mapScalarExpressions(node.left, rewrite), right: mapScalarExpressions(node.right, rewrite) }
|
|
209
|
+
}
|
|
210
|
+
if (node.type === 'function') {
|
|
211
|
+
return { ...node, args: node.args.map(arg => mapScalarExpressions(arg, rewrite)) }
|
|
212
|
+
}
|
|
213
|
+
if (node.type === 'cast') {
|
|
214
|
+
return { ...node, expr: mapScalarExpressions(node.expr, rewrite) }
|
|
215
|
+
}
|
|
216
|
+
if (node.type === 'in valuelist') {
|
|
217
|
+
return {
|
|
218
|
+
...node,
|
|
219
|
+
expr: mapScalarExpressions(node.expr, rewrite),
|
|
220
|
+
values: node.values.map(v => mapScalarExpressions(v, rewrite)),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (node.type === 'subscript') {
|
|
224
|
+
return {
|
|
225
|
+
...node,
|
|
226
|
+
expr: mapScalarExpressions(node.expr, rewrite),
|
|
227
|
+
index: mapScalarExpressions(node.index, rewrite),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (node.type === 'case') {
|
|
231
|
+
return {
|
|
232
|
+
...node,
|
|
233
|
+
caseExpr: mapScalarExpressions(node.caseExpr, rewrite),
|
|
234
|
+
whenClauses: node.whenClauses.map(w => ({
|
|
235
|
+
...w,
|
|
236
|
+
condition: mapScalarExpressions(w.condition, rewrite),
|
|
237
|
+
result: mapScalarExpressions(w.result, rewrite),
|
|
238
|
+
})),
|
|
239
|
+
elseResult: mapScalarExpressions(node.elseResult, rewrite),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return rewrite(node)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Looks up a correlation column's declared primitive type in its lexical scope.
|
|
247
|
+
* Derived relations and CTEs need type propagation before they can use this rewrite.
|
|
248
|
+
* @param {SelectStatement} query
|
|
249
|
+
* @param {IdentifierNode} key
|
|
250
|
+
* @param {Record<string, AsyncDataSource>} [tables]
|
|
251
|
+
* @param {Map<string, string[]>} [cteColumns]
|
|
252
|
+
* @returns {string | undefined}
|
|
253
|
+
*/
|
|
254
|
+
function correlationType(query, key, tables, cteColumns) {
|
|
255
|
+
const { from } = query
|
|
256
|
+
const table = from?.type === 'table' && (from.alias ?? from.table) === key.prefix ? from.table
|
|
257
|
+
: query.joins.find(join => !join.subquery && !join.fromFunction && (join.alias ?? join.table) === key.prefix)?.table
|
|
258
|
+
if (!table || cteColumns?.has(table)) return
|
|
259
|
+
const type = tables?.[table]?.schema?.fields.find(field => field.name === key.name)?.dataType.type
|
|
260
|
+
return type && ['string', 'number', 'bigint', 'boolean'].includes(type) ? type : undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Encodes composite keys with length-prefixed components so ordinary hash
|
|
265
|
+
* grouping and joins cannot confuse tuple boundaries. CONCAT propagates NULL.
|
|
266
|
+
* @param {ExprNode[]} keys
|
|
267
|
+
* @returns {ExprNode}
|
|
268
|
+
*/
|
|
269
|
+
function scalarCorrelationKey(keys) {
|
|
270
|
+
if (keys.length === 1) return keys[0]
|
|
271
|
+
const { positionStart, positionEnd } = keys[0]
|
|
272
|
+
const position = { positionStart, positionEnd }
|
|
273
|
+
return {
|
|
274
|
+
...position, type: 'function', funcName: 'CONCAT',
|
|
275
|
+
args: keys.flatMap(key => {
|
|
276
|
+
/** @type {ExprNode} */
|
|
277
|
+
const text = { ...position, type: 'cast', expr: key, toType: 'TEXT' }
|
|
278
|
+
return [
|
|
279
|
+
/** @type {ExprNode} */ { ...position, type: 'function', funcName: 'LENGTH', args: [text] },
|
|
280
|
+
/** @type {ExprNode} */ { ...position, type: 'literal', value: ':' },
|
|
281
|
+
text,
|
|
282
|
+
]
|
|
283
|
+
}),
|
|
284
|
+
}
|
|
285
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -199,6 +199,8 @@ export interface PlanSqlOptions {
|
|
|
199
199
|
|
|
200
200
|
// executePlan(plan, context)
|
|
201
201
|
export interface ExecuteContext {
|
|
202
|
+
// Delay opening join input scans until their rows are consumed.
|
|
203
|
+
deferScans?: boolean
|
|
202
204
|
tables: Record<string, AsyncDataSource>
|
|
203
205
|
functions?: Record<string, UserDefinedFunction>
|
|
204
206
|
signal?: AbortSignal
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** @import { ExprNode, WindowFunctionNode } from '../types.js' */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {ExprNode | undefined} expr
|
|
5
|
+
* @returns {WindowFunctionNode | undefined}
|
|
6
|
+
*/
|
|
7
|
+
export function findWindow(expr) {
|
|
8
|
+
if (!expr) return undefined
|
|
9
|
+
if (expr.type === 'window') return expr
|
|
10
|
+
if (expr.type === 'binary') return findWindow(expr.left) || findWindow(expr.right)
|
|
11
|
+
if (expr.type === 'unary') return findWindow(expr.argument)
|
|
12
|
+
if (expr.type === 'function') {
|
|
13
|
+
for (const arg of expr.args) {
|
|
14
|
+
const found = findWindow(arg)
|
|
15
|
+
if (found) return found
|
|
16
|
+
}
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
if (expr.type === 'cast') return findWindow(expr.expr)
|
|
20
|
+
if (expr.type === 'subscript') return findWindow(expr.expr) || findWindow(expr.index)
|
|
21
|
+
if (expr.type === 'in valuelist') {
|
|
22
|
+
const found = findWindow(expr.expr)
|
|
23
|
+
if (found) return found
|
|
24
|
+
for (const val of expr.values) {
|
|
25
|
+
const f = findWindow(val)
|
|
26
|
+
if (f) return f
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
if (expr.type === 'case') {
|
|
31
|
+
if (expr.caseExpr) {
|
|
32
|
+
const f = findWindow(expr.caseExpr)
|
|
33
|
+
if (f) return f
|
|
34
|
+
}
|
|
35
|
+
for (const w of expr.whenClauses) {
|
|
36
|
+
const f = findWindow(w.condition) || findWindow(w.result)
|
|
37
|
+
if (f) return f
|
|
38
|
+
}
|
|
39
|
+
if (expr.elseResult) return findWindow(expr.elseResult)
|
|
40
|
+
}
|
|
41
|
+
return undefined
|
|
42
|
+
}
|