squirreling 0.16.10 → 0.16.12
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/join.js +4 -4
- package/src/execute/scanFilter.js +81 -0
- package/src/execute/semiJoin.js +72 -0
- package/src/expression/evaluate.js +5 -1
- package/src/expression/regexp.js +42 -32
- package/src/internalTypes.d.ts +4 -0
- package/src/plan/plan.js +235 -52
- package/src/plan/types.d.ts +2 -0
- package/src/types.d.ts +4 -1
- package/src/validation/functions.js +1 -1
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
|
|
package/src/execute/join.js
CHANGED
|
@@ -60,7 +60,7 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
60
60
|
innerRows.push(row)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const innerCols = innerRows.length ? innerRows[0].columns :
|
|
63
|
+
const innerCols = innerRows.length ? innerRows[0].columns : inner.columns
|
|
64
64
|
|
|
65
65
|
/** @type {string[] | undefined} */
|
|
66
66
|
let outerCols = undefined
|
|
@@ -107,7 +107,7 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
107
107
|
if (matchedInnerRows) {
|
|
108
108
|
for (const innerRow of innerRows) {
|
|
109
109
|
if (!matchedInnerRows.has(innerRow)) {
|
|
110
|
-
yield merge(createNullRow(outerCols ??
|
|
110
|
+
yield merge(createNullRow(outerCols ?? outer.columns), innerRow)
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
}
|
|
@@ -329,7 +329,7 @@ export function executeHashJoin(plan, context) {
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
if (!matched && probeOuter) {
|
|
332
|
-
yield merge(probeRow, createNullRow(buildCols))
|
|
332
|
+
yield merge(probeRow, createNullRow(buildCols.length ? buildCols : build.columns))
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -339,7 +339,7 @@ export function executeHashJoin(plan, context) {
|
|
|
339
339
|
if (buildRows && matchedBuildRows) {
|
|
340
340
|
for (const buildRow of buildRows) {
|
|
341
341
|
if (!matchedBuildRows.has(buildRow)) {
|
|
342
|
-
yield merge(createNullRow(probeCols ??
|
|
342
|
+
yield merge(createNullRow(probeCols ?? probe.columns), buildRow)
|
|
343
343
|
}
|
|
344
344
|
}
|
|
345
345
|
}
|
|
@@ -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
|
+
}
|
package/src/execute/semiJoin.js
CHANGED
|
@@ -4,6 +4,7 @@ import { sqlEquals } from './utils.js'
|
|
|
4
4
|
import { yieldToEventLoop } from './yield.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
+
* @import { KeyTrie } from '../internalTypes.js'
|
|
7
8
|
* @import { ExecuteContext, QueryResults, SqlPrimitive } from '../types.js'
|
|
8
9
|
* @import { SemiJoinNode } from '../plan/types.js'
|
|
9
10
|
*/
|
|
@@ -17,6 +18,7 @@ import { yieldToEventLoop } from './yield.js'
|
|
|
17
18
|
* @returns {QueryResults}
|
|
18
19
|
*/
|
|
19
20
|
export function executeSemiJoin(plan, context) {
|
|
21
|
+
if (plan.keys) return executeExistsJoin(plan, context)
|
|
20
22
|
const left = executePlan({ plan: plan.left, context })
|
|
21
23
|
return {
|
|
22
24
|
columns: left.columns,
|
|
@@ -75,3 +77,73 @@ function contains(values, value) {
|
|
|
75
77
|
}
|
|
76
78
|
return false
|
|
77
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Builds a tuple membership trie once. Type partitions preserve the evaluator's
|
|
83
|
+
* coercive equality without making same-type primitive lookups linear.
|
|
84
|
+
*
|
|
85
|
+
* @param {SemiJoinNode} plan
|
|
86
|
+
* @param {ExecuteContext} context
|
|
87
|
+
* @returns {QueryResults}
|
|
88
|
+
*/
|
|
89
|
+
function executeExistsJoin(plan, context) {
|
|
90
|
+
const left = executePlan({ plan: plan.left, context })
|
|
91
|
+
return {
|
|
92
|
+
columns: left.columns,
|
|
93
|
+
maxRows: left.maxRows,
|
|
94
|
+
async *rows() {
|
|
95
|
+
/** @type {KeyTrie} */
|
|
96
|
+
const root = { groups: new Map() }
|
|
97
|
+
let built = false
|
|
98
|
+
let count = 0
|
|
99
|
+
for await (const row of left.rows()) {
|
|
100
|
+
context.signal?.throwIfAborted()
|
|
101
|
+
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
102
|
+
if (!built) {
|
|
103
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
104
|
+
context.signal?.throwIfAborted()
|
|
105
|
+
if (++count % 4000 === 0) await yieldToEventLoop()
|
|
106
|
+
const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
|
|
107
|
+
if (values.some(value => value == null)) continue
|
|
108
|
+
let node = root
|
|
109
|
+
for (const value of values) {
|
|
110
|
+
const type = typeof value
|
|
111
|
+
let group = node.groups.get(type)
|
|
112
|
+
if (!group) node.groups.set(type, group = new Map())
|
|
113
|
+
let child = group.get(value)
|
|
114
|
+
if (!child) group.set(value, child = { groups: new Map() })
|
|
115
|
+
node = child
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
built = true
|
|
119
|
+
}
|
|
120
|
+
const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
|
|
121
|
+
const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
|
|
122
|
+
if (matched !== plan.not) yield row
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {KeyTrie} node
|
|
130
|
+
* @param {SqlPrimitive[]} values
|
|
131
|
+
* @param {number} index
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
function matchesKeys(node, values, index) {
|
|
135
|
+
if (index === values.length) return true
|
|
136
|
+
const value = values[index]
|
|
137
|
+
const type = typeof value
|
|
138
|
+
if (type !== 'object' && !Number.isNaN(value)) {
|
|
139
|
+
const child = node.groups.get(type)?.get(value)
|
|
140
|
+
if (child && matchesKeys(child, values, index + 1)) return true
|
|
141
|
+
}
|
|
142
|
+
for (const [valueType, group] of node.groups) {
|
|
143
|
+
if (valueType === type && type !== 'object') continue
|
|
144
|
+
for (const [candidate, child] of group) {
|
|
145
|
+
if (sqlEquals(value, candidate) && matchesKeys(child, values, index + 1)) return true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
@@ -713,7 +713,11 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
713
713
|
// IN with subqueries, same three-valued logic as the value-list form
|
|
714
714
|
if (node.type === 'in') {
|
|
715
715
|
const exprVal = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
|
|
716
|
-
const
|
|
716
|
+
const outerScope = context.scope
|
|
717
|
+
const subContext = outerScope
|
|
718
|
+
? { ...context, outerRow: row, outerAliases: new Set(outerScope) }
|
|
719
|
+
: context
|
|
720
|
+
const subResult = executeStatement({ query: node.subquery, context: subContext, outerScope })
|
|
717
721
|
let sawNull = false
|
|
718
722
|
let innerCount = 0
|
|
719
723
|
for await (const resRow of subResult.rows()) {
|
package/src/expression/regexp.js
CHANGED
|
@@ -16,7 +16,22 @@ import { ArgValueError } from '../validation/executionErrors.js'
|
|
|
16
16
|
* @returns {SqlPrimitive}
|
|
17
17
|
*/
|
|
18
18
|
export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
|
|
19
|
-
if (funcName === '
|
|
19
|
+
if (funcName === 'REGEXP_EXTRACT') {
|
|
20
|
+
const [str, pattern] = args
|
|
21
|
+
if (str == null || pattern == null || args.length >= 3 && args[2] == null) return null
|
|
22
|
+
const group = args.length >= 3 ? Number(args[2]) : 0
|
|
23
|
+
if (!Number.isInteger(group) || group < 0) {
|
|
24
|
+
throw new ArgValueError({
|
|
25
|
+
...node,
|
|
26
|
+
message: `group must be a non-negative integer, got ${args[2]}`,
|
|
27
|
+
rowIndex,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
const regex = compileRegexp(node, String(pattern), '', rowIndex)
|
|
31
|
+
return regex.exec(String(str))?.[group] ?? null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (funcName === 'REGEXP_SUBSTR') {
|
|
20
35
|
const str = args[0]
|
|
21
36
|
const pattern = args[1]
|
|
22
37
|
if (str == null || pattern == null) return null
|
|
@@ -51,17 +66,7 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
|
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
let regex
|
|
56
|
-
try {
|
|
57
|
-
regex = new RegExp(patternStr, 'g')
|
|
58
|
-
} catch (/** @type {any} */ error) {
|
|
59
|
-
throw new ArgValueError({
|
|
60
|
-
...node,
|
|
61
|
-
message: `invalid regex pattern: ${error.message}`,
|
|
62
|
-
rowIndex,
|
|
63
|
-
})
|
|
64
|
-
}
|
|
69
|
+
const regex = compileRegexp(node, patternStr, 'g', rowIndex)
|
|
65
70
|
|
|
66
71
|
// Search from position (convert to 0-based)
|
|
67
72
|
const searchStr = strVal.substring(position - 1)
|
|
@@ -74,6 +79,8 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
|
|
|
74
79
|
if (count === occurrence) {
|
|
75
80
|
return match[0]
|
|
76
81
|
}
|
|
82
|
+
// RegExp.exec does not advance after an empty match.
|
|
83
|
+
if (match[0] === '') regex.lastIndex++
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
return null
|
|
@@ -120,17 +127,7 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
|
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
|
|
123
|
-
|
|
124
|
-
let regex
|
|
125
|
-
try {
|
|
126
|
-
regex = new RegExp(patternStr, 'g')
|
|
127
|
-
} catch (/** @type {any} */ error) {
|
|
128
|
-
throw new ArgValueError({
|
|
129
|
-
...node,
|
|
130
|
-
message: `invalid regex pattern: ${error.message}`,
|
|
131
|
-
rowIndex,
|
|
132
|
-
})
|
|
133
|
-
}
|
|
130
|
+
const regex = compileRegexp(node, patternStr, 'g', rowIndex)
|
|
134
131
|
|
|
135
132
|
// If position > 1, preserve the prefix
|
|
136
133
|
const prefix = strVal.substring(0, position - 1)
|
|
@@ -174,15 +171,7 @@ export function evaluateRegexpLike({ node, args, rowIndex, cache }) {
|
|
|
174
171
|
const patternString = String(pattern)
|
|
175
172
|
let regex = cache?.pattern === patternString ? cache.regex : undefined
|
|
176
173
|
if (!regex) {
|
|
177
|
-
|
|
178
|
-
regex = new RegExp(patternString)
|
|
179
|
-
} catch (/** @type {any} */ error) {
|
|
180
|
-
throw new ArgValueError({
|
|
181
|
-
...node,
|
|
182
|
-
message: `invalid regex pattern: ${error.message}`,
|
|
183
|
-
rowIndex,
|
|
184
|
-
})
|
|
185
|
-
}
|
|
174
|
+
regex = compileRegexp(node, patternString, '', rowIndex)
|
|
186
175
|
if (cache) {
|
|
187
176
|
cache.pattern = patternString
|
|
188
177
|
cache.regex = regex
|
|
@@ -194,3 +183,24 @@ export function evaluateRegexpLike({ node, args, rowIndex, cache }) {
|
|
|
194
183
|
// '[object Object]', making REGEXP_LIKE a silent never-match on them.
|
|
195
184
|
return regex.test(typeof string === 'object' ? stringify(string) : String(string))
|
|
196
185
|
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Compile a regex and report invalid patterns as SQL argument errors.
|
|
189
|
+
*
|
|
190
|
+
* @param {FunctionNode} node
|
|
191
|
+
* @param {string} pattern
|
|
192
|
+
* @param {string} flags
|
|
193
|
+
* @param {number} [rowIndex]
|
|
194
|
+
* @returns {RegExp}
|
|
195
|
+
*/
|
|
196
|
+
function compileRegexp(node, pattern, flags, rowIndex) {
|
|
197
|
+
try {
|
|
198
|
+
return new RegExp(pattern, flags)
|
|
199
|
+
} catch (/** @type {any} */ error) {
|
|
200
|
+
throw new ArgValueError({
|
|
201
|
+
...node,
|
|
202
|
+
message: `invalid regex pattern: ${error.message}`,
|
|
203
|
+
rowIndex,
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/internalTypes.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { ColumnReadRequest, ColumnResult, ColumnVector, SqlPrimitive } from './types.js'
|
|
2
2
|
|
|
3
|
+
export interface KeyTrie {
|
|
4
|
+
groups: Map<string, Map<SqlPrimitive, KeyTrie>>
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
export interface CompiledBatchExpression {
|
|
4
8
|
evaluate(context: ColumnReadRequest): ColumnResult
|
|
5
9
|
}
|
package/src/plan/plan.js
CHANGED
|
@@ -8,7 +8,7 @@ import { validateNoIdentifiers, validateScan, validateTableRefs } from '../valid
|
|
|
8
8
|
import { collectColumnsFromExpr, collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, statementScope, tableFunctionColumnNames } from './columns.js'
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
|
|
11
|
+
* @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, FromSubquery, FromTable, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
|
|
12
12
|
* @import { HashJoinNode, QueryPlan, SemiJoinNode, TableFunctionNode, WindowSpec } from './types.js'
|
|
13
13
|
*/
|
|
14
14
|
|
|
@@ -243,6 +243,20 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
243
243
|
for (const conjunct of conjuncts) {
|
|
244
244
|
const not = conjunct.type === 'unary' && conjunct.op === 'NOT'
|
|
245
245
|
const expr = not ? conjunct.argument : conjunct
|
|
246
|
+
if (expr.type === 'exists' || expr.type === 'not exists') {
|
|
247
|
+
const keys = decorrelateExists(expr.subquery, scopeAliases, tables, cteColumns)
|
|
248
|
+
if (keys) {
|
|
249
|
+
const right = planStatement({ stmt: keys.query, tables, ctePlans, cteColumns })
|
|
250
|
+
semiJoins.push({
|
|
251
|
+
type: 'SemiJoin',
|
|
252
|
+
expr: keys.left[0],
|
|
253
|
+
keys: keys.left,
|
|
254
|
+
not: not !== (expr.type === 'not exists'),
|
|
255
|
+
right: { type: 'Subquery', child: right, scope: statementScope(keys.query) ?? [] },
|
|
256
|
+
})
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
}
|
|
246
260
|
if (expr.type !== 'in') {
|
|
247
261
|
residual = andPredicate(residual, conjunct)
|
|
248
262
|
continue
|
|
@@ -253,6 +267,23 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
253
267
|
tables, cteColumns, outerAliases: new Set(scopeAliases),
|
|
254
268
|
})
|
|
255
269
|
if (correlated.length) {
|
|
270
|
+
// Positive correlated IN is EXISTS with an extra equality. NOT IN
|
|
271
|
+
// needs three-valued membership semantics and stays on the fallback.
|
|
272
|
+
// Unqualified operands must stay in the outer scope: moving them
|
|
273
|
+
// into the subquery could bind them to an inner column instead.
|
|
274
|
+
const sub = expr.subquery
|
|
275
|
+
const column = sub.type === 'select' && sub.columns.length === 1 ? sub.columns[0] : undefined
|
|
276
|
+
if (!not && sub.type === 'select' && column?.type === 'derived' &&
|
|
277
|
+
column.expr.type === 'identifier' && expr.expr.type === 'identifier' && expr.expr.prefix) {
|
|
278
|
+
/** @type {ExprNode} */
|
|
279
|
+
const equality = { type: 'binary', op: '=', left: column.expr, right: expr.expr, positionStart: expr.positionStart, positionEnd: expr.positionEnd }
|
|
280
|
+
const keys = decorrelateExists({ ...sub, where: andPredicate(sub.where, equality) }, scopeAliases, tables, cteColumns)
|
|
281
|
+
if (keys) {
|
|
282
|
+
const right = planStatement({ stmt: keys.query, tables, ctePlans, cteColumns })
|
|
283
|
+
semiJoins.push({ type: 'SemiJoin', expr: keys.left[0], keys: keys.left, not: false, right: { type: 'Subquery', child: right, scope: statementScope(keys.query) ?? [] } })
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
}
|
|
256
287
|
residual = andPredicate(residual, conjunct)
|
|
257
288
|
continue
|
|
258
289
|
}
|
|
@@ -279,7 +310,6 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
279
310
|
/** @type {QueryPlan} */
|
|
280
311
|
let plan = planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope })
|
|
281
312
|
pruneAggregateColumns(plan, subqueryNeeds)
|
|
282
|
-
const directFrom = plan.type === 'Scan' && plan.hints === hints
|
|
283
313
|
|
|
284
314
|
// Add JOINs
|
|
285
315
|
if (select.joins.length) {
|
|
@@ -291,9 +321,8 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
291
321
|
|
|
292
322
|
// Add WHERE filter when the scan didn't receive it
|
|
293
323
|
let { where } = select
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
where = pushJoinPredicates(plan, where, tables)
|
|
324
|
+
if (select.joins.length && !outerScope?.length) {
|
|
325
|
+
where = pushJoinPredicates(plan, where, select, tables, cteColumns)
|
|
297
326
|
}
|
|
298
327
|
if (where && !isOwnScan) {
|
|
299
328
|
plan = { type: 'Filter', condition: where, child: plan }
|
|
@@ -876,26 +905,34 @@ function collectConjuncts(node, out) {
|
|
|
876
905
|
}
|
|
877
906
|
|
|
878
907
|
/**
|
|
879
|
-
* Push
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
* order and error short-circuiting are not guaranteed.
|
|
908
|
+
* Push local ON and WHERE conjuncts according to which side is preserved.
|
|
909
|
+
* Only recurse through this SELECT's join chain, never through shared CTEs
|
|
910
|
+
* or derived-table boundaries. Scan hints are copied rather than mutated.
|
|
883
911
|
*
|
|
884
912
|
* @param {QueryPlan} plan
|
|
885
913
|
* @param {ExprNode | undefined} where
|
|
914
|
+
* @param {SelectStatement} select
|
|
886
915
|
* @param {Record<string, AsyncDataSource>} [tables]
|
|
916
|
+
* @param {Map<string, string[]>} [cteColumns]
|
|
917
|
+
* @param {((aliases: string[]) => boolean)[]} [inherited] - proofs required by ancestor filters or inner joins
|
|
887
918
|
* @returns {ExprNode | undefined}
|
|
888
919
|
*/
|
|
889
|
-
function pushJoinPredicates(plan, where, tables) {
|
|
890
|
-
if (!tables || plan.type !== 'HashJoin'
|
|
891
|
-
|
|
892
|
-
const
|
|
893
|
-
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
920
|
+
function pushJoinPredicates(plan, where, select, tables, cteColumns, inherited = []) {
|
|
921
|
+
if (!tables || plan.type !== 'HashJoin' && plan.type !== 'NestedLoopJoin' ||
|
|
922
|
+
select.from?.type === 'function' || select.joins.some(join => join.fromFunction)) return where
|
|
923
|
+
const last = select.joins[select.joins.length - 1]
|
|
924
|
+
const leftSelect = { ...select, joins: select.joins.slice(0, -1) }
|
|
925
|
+
const leftSources = [select.from, ...leftSelect.joins.map(joinSource)]
|
|
926
|
+
const rightSource = joinSource(last)
|
|
927
|
+
const sources = [...leftSources, rightSource]
|
|
928
|
+
const aliases = sources.map(source => fromAlias(source))
|
|
929
|
+
const columns = sources.map(source => source?.type === 'subquery'
|
|
930
|
+
? inferStatementColumns({ stmt: source.query, tables, cteColumns })
|
|
931
|
+
: source?.type === 'table'
|
|
932
|
+
? cteColumns?.get(source.table?.toLowerCase()) ?? dataSourceColumns(tables[source.table])
|
|
933
|
+
: [])
|
|
934
|
+
if (new Set(aliases).size !== aliases.length ||
|
|
935
|
+
columns.some(names => aliases.some(alias => names.includes(alias)))) return where
|
|
899
936
|
|
|
900
937
|
/**
|
|
901
938
|
* @param {ExprNode} expr
|
|
@@ -907,15 +944,14 @@ function pushJoinPredicates(plan, where, tables) {
|
|
|
907
944
|
const matches = columns.map((names, i) => names.includes(expr.name) ? i : -1).filter(i => i >= 0)
|
|
908
945
|
const index = expr.prefix ? aliases.indexOf(expr.prefix) : matches.length === 1 ? matches[0] : -1
|
|
909
946
|
if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
|
|
910
|
-
return index
|
|
947
|
+
return index < leftSources.length ? 'left' : 'right'
|
|
911
948
|
}
|
|
912
949
|
|
|
913
|
-
//
|
|
914
|
-
if (plan.
|
|
950
|
+
// Do not change the interpretation of ambiguous or misoriented hash keys.
|
|
951
|
+
if (plan.type === 'HashJoin' && (plan.leftKeys.some(key => owner(key) !== 'left') ||
|
|
952
|
+
plan.rightKeys.some(key => owner(key) !== 'right'))) return where
|
|
953
|
+
|
|
915
954
|
/**
|
|
916
|
-
* Resolve every reference in a conjunct independently. Subqueries and
|
|
917
|
-
* windows have their own scope and must remain above the join.
|
|
918
|
-
*
|
|
919
955
|
* @param {ExprNode} expr
|
|
920
956
|
* @returns {'left' | 'right' | 'literal' | 'unsafe'}
|
|
921
957
|
*/
|
|
@@ -942,51 +978,147 @@ function pushJoinPredicates(plan, where, tables) {
|
|
|
942
978
|
return target
|
|
943
979
|
}
|
|
944
980
|
|
|
981
|
+
/** @type {ExprNode[]} */
|
|
982
|
+
const whereConjuncts = []
|
|
983
|
+
if (where) collectConjuncts(where, whereConjuncts)
|
|
945
984
|
/**
|
|
946
|
-
*
|
|
947
|
-
*
|
|
985
|
+
* Capture this scope so unqualified references remain unambiguous when
|
|
986
|
+
* proofs are passed down a join chain with fewer visible sources.
|
|
987
|
+
* @param {ExprNode} expr
|
|
988
|
+
* @returns {(missing: string[]) => boolean}
|
|
948
989
|
*/
|
|
949
|
-
function
|
|
950
|
-
return
|
|
951
|
-
|
|
952
|
-
|
|
990
|
+
function rejectionProof(expr) {
|
|
991
|
+
return missing => rejectsNulls(expr, column => {
|
|
992
|
+
if (column.type !== 'identifier') return false
|
|
993
|
+
const matches = columns.map((names, i) => names.includes(column.name) ? i : -1).filter(i => i >= 0)
|
|
994
|
+
const index = column.prefix ? aliases.indexOf(column.prefix) : matches.length === 1 ? matches[0] : -1
|
|
995
|
+
return index >= 0 && columns[index].includes(column.name) && missing.includes(aliases[index])
|
|
953
996
|
})
|
|
954
997
|
}
|
|
998
|
+
const proofs = [...inherited, ...whereConjuncts.map(rejectionProof)]
|
|
999
|
+
const rejectsLeft = proofs.some(proof => proof(aliases.slice(0, -1)))
|
|
1000
|
+
const rejectsRight = proofs.some(proof => proof(aliases.slice(-1)))
|
|
1001
|
+
if (plan.joinType === 'FULL') {
|
|
1002
|
+
if (rejectsLeft && rejectsRight) plan.joinType = 'INNER'
|
|
1003
|
+
else if (rejectsLeft) plan.joinType = 'LEFT'
|
|
1004
|
+
else if (rejectsRight) plan.joinType = 'RIGHT'
|
|
1005
|
+
} else if (plan.joinType === 'LEFT' && rejectsRight || plan.joinType === 'RIGHT' && rejectsLeft) {
|
|
1006
|
+
plan.joinType = 'INNER'
|
|
1007
|
+
}
|
|
955
1008
|
|
|
1009
|
+
const { joinType } = plan
|
|
1010
|
+
/** @type {ExprNode | undefined} */
|
|
1011
|
+
let leftWhere
|
|
1012
|
+
/** @type {ExprNode | undefined} */
|
|
1013
|
+
let rightWhere
|
|
956
1014
|
/**
|
|
957
1015
|
* @param {ExprNode[]} conjuncts
|
|
958
|
-
* @param {
|
|
1016
|
+
* @param {boolean} on
|
|
959
1017
|
* @returns {ExprNode | undefined}
|
|
960
1018
|
*/
|
|
961
|
-
function push(conjuncts,
|
|
1019
|
+
function push(conjuncts, on) {
|
|
962
1020
|
/** @type {ExprNode | undefined} */
|
|
963
1021
|
let residual
|
|
964
|
-
for (
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
}
|
|
1022
|
+
for (const expr of conjuncts) {
|
|
1023
|
+
const side = expressionOwner(expr)
|
|
1024
|
+
const canLeft = joinType === 'INNER' || joinType === (on ? 'RIGHT' : 'LEFT')
|
|
1025
|
+
const canRight = joinType === 'INNER' || joinType === (on ? 'LEFT' : 'RIGHT')
|
|
1026
|
+
if (side === 'left' && canLeft) leftWhere = andPredicate(leftWhere, expr)
|
|
1027
|
+
else if (side === 'right' && canRight) rightWhere = andPredicate(rightWhere, expr)
|
|
1028
|
+
else residual = andPredicate(residual, expr)
|
|
972
1029
|
}
|
|
973
1030
|
return residual
|
|
974
1031
|
}
|
|
975
1032
|
|
|
976
1033
|
/** @type {ExprNode[]} */
|
|
977
1034
|
const onConjuncts = []
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
const onResidual = push(onConjuncts,
|
|
981
|
-
if (
|
|
982
|
-
|
|
1035
|
+
const condition = plan.type === 'HashJoin' ? plan.residual : plan.condition
|
|
1036
|
+
if (condition) collectConjuncts(condition, onConjuncts)
|
|
1037
|
+
const onResidual = push(onConjuncts, true)
|
|
1038
|
+
if (plan.type === 'HashJoin') {
|
|
1039
|
+
if (onResidual) plan.residual = onResidual
|
|
1040
|
+
else delete plan.residual
|
|
1041
|
+
} else if (condition) {
|
|
1042
|
+
// Nested-loop joins still need a condition when every conjunct was pushed.
|
|
1043
|
+
plan.condition = onResidual ?? {
|
|
1044
|
+
type: 'literal', value: true,
|
|
1045
|
+
positionStart: condition.positionStart, positionEnd: condition.positionEnd,
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
const residual = push(whereConjuncts, false)
|
|
1049
|
+
if (leftSelect.joins.length) {
|
|
1050
|
+
// An inner join requires its ON condition to succeed, so it can reject
|
|
1051
|
+
// NULL rows from an earlier outer join. An outer join's own ON cannot.
|
|
1052
|
+
const leftProofs = joinType === 'INNER' || joinType === 'LEFT' ? [...proofs] : []
|
|
1053
|
+
if (joinType === 'INNER') {
|
|
1054
|
+
if (condition) leftProofs.push(rejectionProof(condition))
|
|
1055
|
+
if (plan.type === 'HashJoin') leftProofs.push(...plan.leftKeys.map(rejectionProof))
|
|
1056
|
+
}
|
|
1057
|
+
leftWhere = pushJoinPredicates(plan.left, leftWhere, leftSelect, tables, cteColumns, leftProofs)
|
|
1058
|
+
}
|
|
1059
|
+
plan.left = filterJoinInput(plan.left, leftWhere)
|
|
1060
|
+
plan.right = filterJoinInput(plan.right, rightWhere)
|
|
1061
|
+
return residual
|
|
1062
|
+
}
|
|
983
1063
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
return
|
|
1064
|
+
/**
|
|
1065
|
+
* @param {JoinClause} join
|
|
1066
|
+
* @returns {FromSubquery | FromTable}
|
|
1067
|
+
*/
|
|
1068
|
+
function joinSource(join) {
|
|
1069
|
+
return join.subquery
|
|
1070
|
+
? { ...join.subquery, alias: join.alias }
|
|
1071
|
+
: { ...join, type: 'table' }
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Keep LIMIT, projection and shared CTE plans intact. A filter outside these
|
|
1076
|
+
* boundaries still reduces the rows entering the join.
|
|
1077
|
+
*
|
|
1078
|
+
* @param {QueryPlan} plan
|
|
1079
|
+
* @param {ExprNode | undefined} where
|
|
1080
|
+
* @returns {QueryPlan}
|
|
1081
|
+
*/
|
|
1082
|
+
function filterJoinInput(plan, where) {
|
|
1083
|
+
if (!where) return plan
|
|
1084
|
+
if (plan.type === 'Scan' && plan.hints.limit === undefined && !plan.hints.offset) {
|
|
1085
|
+
return { ...plan, hints: { ...plan.hints, where: andPredicate(plan.hints.where, where) } }
|
|
1086
|
+
}
|
|
1087
|
+
return { type: 'Filter', condition: where, child: plan }
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Conservative proof for an expression with references to the side being
|
|
1092
|
+
* NULL-extended. Unknown functions (including COALESCE) and CASE
|
|
1093
|
+
* are deliberately not assumed to propagate NULL.
|
|
1094
|
+
*
|
|
1095
|
+
* @param {ExprNode} expr
|
|
1096
|
+
* @param {(expr: ExprNode) => boolean} isMissing
|
|
1097
|
+
* @returns {boolean}
|
|
1098
|
+
*/
|
|
1099
|
+
function nullWhenMissing(expr, isMissing) {
|
|
1100
|
+
if (expr.type === 'identifier') return isMissing(expr)
|
|
1101
|
+
if (expr.type === 'literal') return expr.value == null
|
|
1102
|
+
if (expr.type === 'cast') return nullWhenMissing(expr.expr, isMissing)
|
|
1103
|
+
if (expr.type === 'unary') return ['NOT', '+', '-'].includes(expr.op) && nullWhenMissing(expr.argument, isMissing)
|
|
1104
|
+
if (expr.type === 'in valuelist') return nullWhenMissing(expr.expr, isMissing)
|
|
1105
|
+
if (expr.type === 'binary' && ['=', '==', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', '+', '-', '*', '/', '%', '||'].includes(expr.op)) {
|
|
1106
|
+
return nullWhenMissing(expr.left, isMissing) || nullWhenMissing(expr.right, isMissing)
|
|
1107
|
+
}
|
|
1108
|
+
return false
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* @param {ExprNode} expr
|
|
1113
|
+
* @param {(expr: ExprNode) => boolean} isMissing
|
|
1114
|
+
* @returns {boolean}
|
|
1115
|
+
*/
|
|
1116
|
+
function rejectsNulls(expr, isMissing) {
|
|
1117
|
+
if (nullWhenMissing(expr, isMissing)) return true
|
|
1118
|
+
if (expr.type === 'unary' && expr.op === 'IS NOT NULL') return nullWhenMissing(expr.argument, isMissing)
|
|
1119
|
+
if (expr.type === 'binary' && expr.op === 'AND') return rejectsNulls(expr.left, isMissing) || rejectsNulls(expr.right, isMissing)
|
|
1120
|
+
if (expr.type === 'binary' && expr.op === 'OR') return rejectsNulls(expr.left, isMissing) && rejectsNulls(expr.right, isMissing)
|
|
1121
|
+
return false
|
|
990
1122
|
}
|
|
991
1123
|
|
|
992
1124
|
/**
|
|
@@ -1224,3 +1356,54 @@ function isAllCountStar(columns) {
|
|
|
1224
1356
|
!col.expr.filter
|
|
1225
1357
|
)
|
|
1226
1358
|
}
|
|
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
|
+
}
|
package/src/plan/types.d.ts
CHANGED
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
|
|
@@ -131,7 +131,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
131
131
|
SPLIT_PART: { min: 3, max: 3, signature: 'string, delimiter, index' },
|
|
132
132
|
STRING_SPLIT: { min: 2, max: 2, signature: 'string, delimiter' },
|
|
133
133
|
REGEXP_SUBSTR: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
|
|
134
|
-
REGEXP_EXTRACT: { min: 2, max:
|
|
134
|
+
REGEXP_EXTRACT: { min: 2, max: 3, signature: 'string, pattern[, group]' },
|
|
135
135
|
REGEXP_REPLACE: { min: 3, max: 5, signature: 'string, pattern, replacement[, position[, occurrence]]' },
|
|
136
136
|
REGEXP_MATCHES: { min: 2, max: 2, signature: 'string, pattern' },
|
|
137
137
|
REGEXP_LIKE: { min: 2, max: 2, signature: 'string, pattern' },
|