squirreling 0.16.11 → 0.16.13
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 +1 -1
- package/src/execute/aggregates.js +4 -1
- package/src/execute/execute.js +67 -32
- package/src/execute/scanFilter.js +81 -0
- 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 +4 -1
- package/src/validation/windows.js +42 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumu
|
|
|
5
5
|
import { executePlan, executeScan, selectColumnNames } from './execute.js'
|
|
6
6
|
import { foldEvaluatedRows } from './fold.js'
|
|
7
7
|
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
8
|
+
import { scanFilter } from './scanFilter.js'
|
|
8
9
|
import { sortEntriesByTerms } from './sort.js'
|
|
9
10
|
import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
|
|
10
11
|
import { keyify } from './utils.js'
|
|
@@ -289,10 +290,12 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
|
289
290
|
// Child must be a direct table scan
|
|
290
291
|
if (plan.child.type !== 'Scan') return
|
|
291
292
|
const scanNode = plan.child
|
|
292
|
-
const { limit, offset
|
|
293
|
+
const { limit, offset } = scanNode.hints
|
|
293
294
|
|
|
294
295
|
const table = tables[scanNode.table]
|
|
295
296
|
if (!table?.scanColumn) return
|
|
297
|
+
const { where, residual } = scanFilter(scanNode, dataSourceColumns(table))
|
|
298
|
+
if (residual) return
|
|
296
299
|
|
|
297
300
|
// COUNT(*) needs a physical column whose filtered chunk lengths can be
|
|
298
301
|
// counted. Prefer a predicate/projection column, then any table column.
|
package/src/execute/execute.js
CHANGED
|
@@ -14,6 +14,7 @@ import { distinctBatches, filterBatches, limitBatches, projectExpressionBatches
|
|
|
14
14
|
import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
|
|
15
15
|
import { referencesRowScope } from './rowScope.js'
|
|
16
16
|
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
17
|
+
import { scanFilter } from './scanFilter.js'
|
|
17
18
|
import { executeSort } from './sort.js'
|
|
18
19
|
import { executeSemiJoin } from './semiJoin.js'
|
|
19
20
|
import { addBounds, minBounds, stableRowKey } from './utils.js'
|
|
@@ -317,35 +318,51 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
317
318
|
const { tables, signal } = context
|
|
318
319
|
const table = validateTable({ ...plan, tables })
|
|
319
320
|
validateScan({ ...plan, tables })
|
|
320
|
-
const
|
|
321
|
+
const { where, residual } = scanFilter(plan, dataSourceColumns(table))
|
|
322
|
+
const hints = { ...plan.hints, where }
|
|
323
|
+
if (residual) {
|
|
324
|
+
// Keep range and Top-K outside the scan until the engine residual runs.
|
|
325
|
+
delete hints.limit
|
|
326
|
+
delete hints.offset
|
|
327
|
+
}
|
|
328
|
+
const hasLimitOffset = hints.limit !== undefined || hints.offset // 0 offset is noop
|
|
321
329
|
const scanContext = { ...context, scope: [plan.alias ?? plan.table] }
|
|
322
330
|
|
|
323
331
|
if (!existingColumnResult && table.prepareScan && table.schema) {
|
|
324
|
-
|
|
325
|
-
|
|
332
|
+
// Derive column demands from the original predicate, including fields
|
|
333
|
+
// needed by engine residuals, but send only the source-local predicate.
|
|
334
|
+
const request = scanRequest(plan, table.schema, scanContext)
|
|
335
|
+
request.filter = where
|
|
336
|
+
if (residual) {
|
|
337
|
+
delete request.limit
|
|
338
|
+
delete request.offset
|
|
339
|
+
delete request.topK
|
|
340
|
+
}
|
|
341
|
+
const prepared = table.prepareScan(request)
|
|
342
|
+
return finish(executePreparedScan({ plan: { ...plan, hints }, prepared, context: scanContext, table }))
|
|
326
343
|
}
|
|
327
344
|
|
|
328
345
|
// Fast path: single column scan. As with scan(), hints the source did not
|
|
329
346
|
// apply are handled by the engine over the returned column values.
|
|
330
|
-
const scanColumnOptions =
|
|
331
|
-
?
|
|
347
|
+
const scanColumnOptions = hints.columns?.length === 1
|
|
348
|
+
? hints.where
|
|
332
349
|
// Do not push a filtered range until WHERE is known to be applied: an
|
|
333
350
|
// older source may ignore WHERE but eagerly apply LIMIT/OFFSET.
|
|
334
|
-
? { column:
|
|
335
|
-
: { column:
|
|
351
|
+
? { column: hints.columns[0], where: hints.where, signal }
|
|
352
|
+
: { column: hints.columns[0], ...hints, signal }
|
|
336
353
|
: undefined
|
|
337
354
|
const columnResult = existingColumnResult ?? (table.scanColumn && scanColumnOptions
|
|
338
355
|
? normalizeScanColumnResult(table.scanColumn(scanColumnOptions), scanColumnOptions)
|
|
339
356
|
: undefined)
|
|
340
357
|
if (columnResult && scanColumnOptions) {
|
|
341
|
-
const column =
|
|
342
|
-
const scanRows = computeScanRows(table.numRows,
|
|
358
|
+
const column = hints.columns[0]
|
|
359
|
+
const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
|
|
343
360
|
const columns = [column]
|
|
344
|
-
const appliedLimitOffset =
|
|
361
|
+
const appliedLimitOffset = hints.where
|
|
345
362
|
? false
|
|
346
363
|
: columnResult.appliedLimitOffset
|
|
347
|
-
const residualFilter =
|
|
348
|
-
? compileBatchExpression(
|
|
364
|
+
const residualFilter = hints.where && !columnResult.appliedWhere
|
|
365
|
+
? compileBatchExpression(hints.where, columns)
|
|
349
366
|
: undefined
|
|
350
367
|
|
|
351
368
|
/** @returns {AsyncIterable<AsyncBatch>} */
|
|
@@ -353,28 +370,28 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
353
370
|
/** @type {AsyncIterable<AsyncBatch>} */
|
|
354
371
|
let batches = columnBatches(columnResult.chunks(), signal)
|
|
355
372
|
if (residualFilter) {
|
|
356
|
-
const targetRows =
|
|
373
|
+
const targetRows = hints.limit === undefined
|
|
357
374
|
? undefined
|
|
358
|
-
:
|
|
375
|
+
: hints.limit + (hints.offset ?? 0)
|
|
359
376
|
batches = filterBatches(batches, residualFilter, signal, targetRows)
|
|
360
377
|
}
|
|
361
378
|
if (!appliedLimitOffset && hasLimitOffset) {
|
|
362
|
-
batches = limitBatches(batches,
|
|
379
|
+
batches = limitBatches(batches, hints.limit, hints.offset, signal)
|
|
363
380
|
}
|
|
364
381
|
return batches
|
|
365
382
|
}
|
|
366
383
|
|
|
367
|
-
const canUseBatches = !
|
|
384
|
+
const canUseBatches = !hints.where || columnResult.appliedWhere || residualFilter !== undefined
|
|
368
385
|
if (canUseBatches) {
|
|
369
|
-
return batchResult({
|
|
386
|
+
return finish(batchResult({
|
|
370
387
|
columns,
|
|
371
|
-
numRows:
|
|
388
|
+
numRows: hints.where ? undefined : scanRows,
|
|
372
389
|
maxRows: scanRows,
|
|
373
390
|
batches: makeBatches,
|
|
374
391
|
signal,
|
|
375
|
-
})
|
|
392
|
+
}))
|
|
376
393
|
}
|
|
377
|
-
return {
|
|
394
|
+
return finish({
|
|
378
395
|
columns,
|
|
379
396
|
numRows: undefined,
|
|
380
397
|
maxRows: scanRows,
|
|
@@ -395,10 +412,10 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
395
412
|
}
|
|
396
413
|
})()
|
|
397
414
|
|
|
398
|
-
result = filterRows(result,
|
|
415
|
+
result = filterRows(result, hints.where, scanContext, hints.limit)
|
|
399
416
|
// Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
|
|
400
417
|
if (!appliedLimitOffset && hasLimitOffset) {
|
|
401
|
-
result = limitRows(result,
|
|
418
|
+
result = limitRows(result, hints.limit, hints.offset, signal)
|
|
402
419
|
}
|
|
403
420
|
|
|
404
421
|
yield* result
|
|
@@ -406,7 +423,7 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
406
423
|
// the abort so a truncated scan is not mistaken for a complete one
|
|
407
424
|
signal?.throwIfAborted()
|
|
408
425
|
},
|
|
409
|
-
}
|
|
426
|
+
})
|
|
410
427
|
}
|
|
411
428
|
|
|
412
429
|
if (!table.scan) {
|
|
@@ -414,30 +431,30 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
414
431
|
}
|
|
415
432
|
|
|
416
433
|
// do the scan
|
|
417
|
-
const scanResult = table.scan({ ...
|
|
434
|
+
const scanResult = table.scan({ ...hints, signal })
|
|
418
435
|
const { appliedWhere, appliedLimitOffset } = scanResult
|
|
419
436
|
|
|
420
437
|
// Applied limit/offset without applied where is invalid
|
|
421
|
-
if (!appliedWhere && appliedLimitOffset &&
|
|
438
|
+
if (!appliedWhere && appliedLimitOffset && hints.where && hasLimitOffset) {
|
|
422
439
|
throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
|
|
423
440
|
}
|
|
424
441
|
|
|
425
|
-
const scanRows = computeScanRows(table.numRows,
|
|
426
|
-
return {
|
|
427
|
-
columns:
|
|
428
|
-
numRows: !
|
|
442
|
+
const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
|
|
443
|
+
return finish({
|
|
444
|
+
columns: hints.columns ?? dataSourceColumns(table),
|
|
445
|
+
numRows: !hints.where ? scanRows : undefined,
|
|
429
446
|
maxRows: scanRows,
|
|
430
447
|
async *rows() {
|
|
431
448
|
let result = scanResult.rows()
|
|
432
449
|
|
|
433
450
|
// Apply WHERE if data source did not
|
|
434
|
-
if (!appliedWhere &&
|
|
435
|
-
result = filterRows(result,
|
|
451
|
+
if (!appliedWhere && hints.where) {
|
|
452
|
+
result = filterRows(result, hints.where, scanContext, hints.limit)
|
|
436
453
|
}
|
|
437
454
|
|
|
438
455
|
// Apply LIMIT/OFFSET if data source did not
|
|
439
456
|
if (!appliedLimitOffset && hasLimitOffset) {
|
|
440
|
-
result = limitRows(result,
|
|
457
|
+
result = limitRows(result, hints.limit, hints.offset, signal)
|
|
441
458
|
}
|
|
442
459
|
|
|
443
460
|
yield* result
|
|
@@ -446,6 +463,24 @@ export function executeScan(plan, context, existingColumnResult) {
|
|
|
446
463
|
// the abort so a truncated scan is not mistaken for a complete one
|
|
447
464
|
signal?.throwIfAborted()
|
|
448
465
|
},
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Apply engine-only predicates and ranges after any source execution path.
|
|
470
|
+
* @param {QueryResults} source
|
|
471
|
+
* @returns {QueryResults}
|
|
472
|
+
*/
|
|
473
|
+
function finish(source) {
|
|
474
|
+
if (!residual) return source
|
|
475
|
+
const { limit, offset } = plan.hints
|
|
476
|
+
return {
|
|
477
|
+
columns: source.columns,
|
|
478
|
+
maxRows: computeScanRows(source.maxRows, limit, offset),
|
|
479
|
+
async *rows() {
|
|
480
|
+
const filtered = filterRows(source.rows(), residual, scanContext, limit)
|
|
481
|
+
yield* limitRows(filtered, limit, offset, signal)
|
|
482
|
+
},
|
|
483
|
+
}
|
|
449
484
|
}
|
|
450
485
|
}
|
|
451
486
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ExprNode } from '../types.js'
|
|
3
|
+
* @import { ScanNode } from '../plan/types.js'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translate local conjuncts into physical column names. Keep subqueries and
|
|
8
|
+
* references outside this scan in the engine. Match scope names exactly, as
|
|
9
|
+
* expression evaluation does, including quoted identifiers and case.
|
|
10
|
+
*
|
|
11
|
+
* @param {ScanNode} plan
|
|
12
|
+
* @param {string[]} columns
|
|
13
|
+
* @returns {{ where?: ExprNode, residual?: ExprNode }}
|
|
14
|
+
*/
|
|
15
|
+
export function scanFilter(plan, columns) {
|
|
16
|
+
const scope = plan.alias ?? plan.table
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {ExprNode} node
|
|
20
|
+
* @returns {{ where?: ExprNode, residual?: ExprNode }}
|
|
21
|
+
*/
|
|
22
|
+
function split(node) {
|
|
23
|
+
if (!node) return {}
|
|
24
|
+
if (node.type === 'binary' && node.op === 'AND') {
|
|
25
|
+
const left = split(node.left)
|
|
26
|
+
const right = split(node.right)
|
|
27
|
+
return {
|
|
28
|
+
where: combine(left.where, right.where),
|
|
29
|
+
residual: combine(left.residual, right.residual),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let safe = true
|
|
33
|
+
/**
|
|
34
|
+
* @param {ExprNode} expr
|
|
35
|
+
* @returns {ExprNode}
|
|
36
|
+
*/
|
|
37
|
+
function rewrite(expr) {
|
|
38
|
+
if (!expr) return expr
|
|
39
|
+
if (expr.type === 'identifier') {
|
|
40
|
+
if (expr.prefix && expr.prefix !== scope || !columns.includes(expr.name) ||
|
|
41
|
+
expr.prefix && columns.includes(`${expr.prefix}.${expr.name}`)) {
|
|
42
|
+
safe = false
|
|
43
|
+
return expr
|
|
44
|
+
}
|
|
45
|
+
if (!expr.prefix) return expr
|
|
46
|
+
const bare = { ...expr }
|
|
47
|
+
delete bare.prefix
|
|
48
|
+
return bare
|
|
49
|
+
}
|
|
50
|
+
if (expr.type === 'literal' || expr.type === 'interval') return expr
|
|
51
|
+
if (expr.type === 'binary') return { ...expr, left: rewrite(expr.left), right: rewrite(expr.right) }
|
|
52
|
+
if (expr.type === 'unary') return { ...expr, argument: rewrite(expr.argument) }
|
|
53
|
+
if (expr.type === 'cast') return { ...expr, expr: rewrite(expr.expr) }
|
|
54
|
+
if (expr.type === 'function') return { ...expr, args: expr.args.map(rewrite), ...expr.filter ? { filter: rewrite(expr.filter) } : {} }
|
|
55
|
+
if (expr.type === 'in valuelist') return { ...expr, expr: rewrite(expr.expr), values: expr.values.map(rewrite) }
|
|
56
|
+
if (expr.type === 'subscript') return { ...expr, expr: rewrite(expr.expr), index: rewrite(expr.index) }
|
|
57
|
+
if (expr.type === 'case') return {
|
|
58
|
+
...expr,
|
|
59
|
+
caseExpr: rewrite(expr.caseExpr),
|
|
60
|
+
whenClauses: expr.whenClauses.map(w => ({ ...w, condition: rewrite(w.condition), result: rewrite(w.result) })),
|
|
61
|
+
elseResult: rewrite(expr.elseResult),
|
|
62
|
+
}
|
|
63
|
+
safe = false
|
|
64
|
+
return expr
|
|
65
|
+
}
|
|
66
|
+
const where = rewrite(node)
|
|
67
|
+
return safe ? { where } : { residual: node }
|
|
68
|
+
}
|
|
69
|
+
return split(plan.hints.where)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {ExprNode} left
|
|
74
|
+
* @param {ExprNode} right
|
|
75
|
+
* @returns {ExprNode}
|
|
76
|
+
*/
|
|
77
|
+
function combine(left, right) {
|
|
78
|
+
if (!left) return right
|
|
79
|
+
if (!right) return left
|
|
80
|
+
return { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
|
|
81
|
+
}
|
|
@@ -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
|
@@ -145,6 +145,7 @@ export interface ScanTopK {
|
|
|
145
145
|
export interface ScanRequest {
|
|
146
146
|
columns: readonly ColumnDemand[]
|
|
147
147
|
topK?: ScanTopK
|
|
148
|
+
/** Source-local predicate: identifiers use bare physical column names. */
|
|
148
149
|
filter?: ExprNode
|
|
149
150
|
limit?: number
|
|
150
151
|
offset?: number
|
|
@@ -271,7 +272,8 @@ export interface ScanResults {
|
|
|
271
272
|
*/
|
|
272
273
|
export interface ScanOptions {
|
|
273
274
|
columns?: string[] // columns needed (undefined means all columns)
|
|
274
|
-
|
|
275
|
+
/** Source-local predicate: identifiers use bare physical column names. */
|
|
276
|
+
where?: ExprNode
|
|
275
277
|
// important: only apply limit/offset if where is fully applied by the data source
|
|
276
278
|
// otherwise, the data source must return at least enough rows to ensure the engine
|
|
277
279
|
// can apply limit/offset correctly after filtering
|
|
@@ -285,6 +287,7 @@ export interface ScanOptions {
|
|
|
285
287
|
*/
|
|
286
288
|
export interface ScanColumnOptions {
|
|
287
289
|
column: string
|
|
290
|
+
/** Source-local predicate: identifiers use bare physical column names. */
|
|
288
291
|
where?: ExprNode
|
|
289
292
|
limit?: number
|
|
290
293
|
offset?: number
|
|
@@ -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
|
+
}
|