squirreling 0.16.9 → 0.16.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/execute/execute.js +3 -0
- package/src/execute/join.js +4 -4
- package/src/execute/semiJoin.js +149 -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 +308 -64
- package/src/plan/types.d.ts +12 -0
- package/src/validation/functions.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.11",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@eslint/js": "10.0.1",
|
|
43
43
|
"@types/node": "26.6.5",
|
|
44
|
-
"@vitest/coverage-v8": "5.0.
|
|
45
|
-
"eslint": "10.
|
|
46
|
-
"eslint-plugin-jsdoc": "65.
|
|
44
|
+
"@vitest/coverage-v8": "5.0.5",
|
|
45
|
+
"eslint": "10.13.0",
|
|
46
|
+
"eslint-plugin-jsdoc": "65.4.0",
|
|
47
47
|
"globals": "17.13.0",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
|
-
"vitest": "5.0.
|
|
49
|
+
"vitest": "5.0.5"
|
|
50
50
|
}
|
|
51
51
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -15,6 +15,7 @@ import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from '.
|
|
|
15
15
|
import { referencesRowScope } from './rowScope.js'
|
|
16
16
|
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
17
17
|
import { executeSort } from './sort.js'
|
|
18
|
+
import { executeSemiJoin } from './semiJoin.js'
|
|
18
19
|
import { addBounds, minBounds, stableRowKey } from './utils.js'
|
|
19
20
|
import { executeWindow } from './window.js'
|
|
20
21
|
import { yieldToEventLoop } from './yield.js'
|
|
@@ -108,6 +109,8 @@ export function executePlan({ plan, context }) {
|
|
|
108
109
|
return executeFilter(plan, context)
|
|
109
110
|
} else if (plan.type === 'Project') {
|
|
110
111
|
return executeProject(plan, context)
|
|
112
|
+
} else if (plan.type === 'SemiJoin') {
|
|
113
|
+
return executeSemiJoin(plan, context)
|
|
111
114
|
} else if (plan.type === 'HashJoin') {
|
|
112
115
|
return executeHashJoin(plan, context)
|
|
113
116
|
} else if (plan.type === 'NestedLoopJoin') {
|
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,149 @@
|
|
|
1
|
+
import { evaluateExpr } from '../expression/evaluate.js'
|
|
2
|
+
import { executePlan } from './execute.js'
|
|
3
|
+
import { sqlEquals } from './utils.js'
|
|
4
|
+
import { yieldToEventLoop } from './yield.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @import { KeyTrie } from '../internalTypes.js'
|
|
8
|
+
* @import { ExecuteContext, QueryResults, SqlPrimitive } from '../types.js'
|
|
9
|
+
* @import { SemiJoinNode } from '../plan/types.js'
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Executes WHERE IN / NOT IN with one build of the right input per iteration.
|
|
14
|
+
* The membership set belongs to this operator, never to the context or AST.
|
|
15
|
+
*
|
|
16
|
+
* @param {SemiJoinNode} plan
|
|
17
|
+
* @param {ExecuteContext} context
|
|
18
|
+
* @returns {QueryResults}
|
|
19
|
+
*/
|
|
20
|
+
export function executeSemiJoin(plan, context) {
|
|
21
|
+
if (plan.keys) return executeExistsJoin(plan, context)
|
|
22
|
+
const left = executePlan({ plan: plan.left, context })
|
|
23
|
+
return {
|
|
24
|
+
columns: left.columns,
|
|
25
|
+
maxRows: left.maxRows,
|
|
26
|
+
async *rows() {
|
|
27
|
+
/** @type {Map<string, Set<SqlPrimitive>>} */
|
|
28
|
+
const values = new Map()
|
|
29
|
+
let hasNull = false
|
|
30
|
+
let count = 0
|
|
31
|
+
let built = false
|
|
32
|
+
let rowIndex = 0
|
|
33
|
+
for await (const row of left.rows()) {
|
|
34
|
+
context.signal?.throwIfAborted()
|
|
35
|
+
if (++rowIndex % 4000 === 0) await yieldToEventLoop()
|
|
36
|
+
const value = await evaluateExpr({ node: plan.expr, row, rowIndex, context })
|
|
37
|
+
if (!built) {
|
|
38
|
+
for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
|
|
39
|
+
if (++count % 4000 === 0) {
|
|
40
|
+
await yieldToEventLoop()
|
|
41
|
+
context.signal?.throwIfAborted()
|
|
42
|
+
}
|
|
43
|
+
const value = await inner.cells[inner.columns[0]]()
|
|
44
|
+
if (value == null) hasNull = true
|
|
45
|
+
else {
|
|
46
|
+
const type = typeof value
|
|
47
|
+
let group = values.get(type)
|
|
48
|
+
if (!group) values.set(type, group = new Set())
|
|
49
|
+
group.add(value)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
built = true
|
|
53
|
+
}
|
|
54
|
+
const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
|
|
55
|
+
if (matched !== null && matched !== plan.not) yield row
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Same-type primitives use a Set. Cross-type values and Dates retain the
|
|
63
|
+
* evaluator's SQL equality, including coercion and timestamp comparison.
|
|
64
|
+
*
|
|
65
|
+
* @param {Map<string, Set<SqlPrimitive>>} values
|
|
66
|
+
* @param {SqlPrimitive} value
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function contains(values, value) {
|
|
70
|
+
const type = typeof value
|
|
71
|
+
if (type !== 'object' && !(typeof value === 'number' && Number.isNaN(value)) && values.get(type)?.has(value)) return true
|
|
72
|
+
for (const [valueType, group] of values) {
|
|
73
|
+
if (valueType === type && type !== 'object') continue
|
|
74
|
+
for (const candidate of group) {
|
|
75
|
+
if (sqlEquals(value, candidate)) return true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
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,8 +8,8 @@ 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'
|
|
12
|
-
* @import { HashJoinNode, QueryPlan, TableFunctionNode, WindowSpec } 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
|
+
* @import { HashJoinNode, QueryPlan, SemiJoinNode, TableFunctionNode, WindowSpec } from './types.js'
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -230,9 +230,77 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
230
230
|
if (hints.columns?.length === 0 && select.from?.type === 'subquery') {
|
|
231
231
|
hints.columns = undefined
|
|
232
232
|
}
|
|
233
|
+
// A WHERE membership test is a semi-join, not a row-by-row expression
|
|
234
|
+
// subquery. Keep the original SELECT for column demand analysis above.
|
|
235
|
+
/** @type {Omit<SemiJoinNode, 'left'>[]} */
|
|
236
|
+
const semiJoins = []
|
|
237
|
+
if (select.where && !outerScope?.length) {
|
|
238
|
+
/** @type {ExprNode[]} */
|
|
239
|
+
const conjuncts = []
|
|
240
|
+
collectConjuncts(select.where, conjuncts)
|
|
241
|
+
/** @type {ExprNode | undefined} */
|
|
242
|
+
let residual
|
|
243
|
+
for (const conjunct of conjuncts) {
|
|
244
|
+
const not = conjunct.type === 'unary' && conjunct.op === 'NOT'
|
|
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
|
+
}
|
|
260
|
+
if (expr.type !== 'in') {
|
|
261
|
+
residual = andPredicate(residual, conjunct)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
/** @type {IdentifierNode[]} */
|
|
265
|
+
const correlated = []
|
|
266
|
+
collectColumnsFromExpr({ type: 'subquery', subquery: expr.subquery, positionStart: expr.positionStart, positionEnd: expr.positionEnd }, correlated, undefined, {
|
|
267
|
+
tables, cteColumns, outerAliases: new Set(scopeAliases),
|
|
268
|
+
})
|
|
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
|
+
}
|
|
287
|
+
residual = andPredicate(residual, conjunct)
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const right = planStatement({ stmt: expr.subquery, tables, ctePlans, cteColumns })
|
|
291
|
+
const scope = statementScope(expr.subquery)
|
|
292
|
+
semiJoins.push({
|
|
293
|
+
type: 'SemiJoin',
|
|
294
|
+
expr: expr.expr,
|
|
295
|
+
not,
|
|
296
|
+
right: scope ? { type: 'Subquery', child: right, scope } : right,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
select = { ...select, where: residual }
|
|
300
|
+
}
|
|
233
301
|
if (!select.joins.length) {
|
|
234
302
|
hints.where = select.where
|
|
235
|
-
if (!needsBuffering && !select.distinct) {
|
|
303
|
+
if (!needsBuffering && !select.distinct && !semiJoins.length) {
|
|
236
304
|
hints.limit = select.limit
|
|
237
305
|
hints.offset = select.offset
|
|
238
306
|
}
|
|
@@ -242,7 +310,6 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
242
310
|
/** @type {QueryPlan} */
|
|
243
311
|
let plan = planFrom({ select, ctePlans, cteColumns, hints, tables, outerScope })
|
|
244
312
|
pruneAggregateColumns(plan, subqueryNeeds)
|
|
245
|
-
const directFrom = plan.type === 'Scan' && plan.hints === hints
|
|
246
313
|
|
|
247
314
|
// Add JOINs
|
|
248
315
|
if (select.joins.length) {
|
|
@@ -254,14 +321,15 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
254
321
|
|
|
255
322
|
// Add WHERE filter when the scan didn't receive it
|
|
256
323
|
let { where } = select
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
where = pushJoinPredicates(plan, where, tables)
|
|
324
|
+
if (select.joins.length && !outerScope?.length) {
|
|
325
|
+
where = pushJoinPredicates(plan, where, select, tables, cteColumns)
|
|
260
326
|
}
|
|
261
327
|
if (where && !isOwnScan) {
|
|
262
328
|
plan = { type: 'Filter', condition: where, child: plan }
|
|
263
329
|
}
|
|
264
330
|
|
|
331
|
+
for (const semiJoin of semiJoins) plan = { ...semiJoin, left: plan }
|
|
332
|
+
|
|
265
333
|
if (useGrouping) {
|
|
266
334
|
// Aggregation path: GROUP BY or scalar aggregate
|
|
267
335
|
// HAVING is integrated into aggregate nodes for access to group context
|
|
@@ -346,7 +414,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
|
|
|
346
414
|
plan = { type: 'Distinct', child: plan }
|
|
347
415
|
}
|
|
348
416
|
|
|
349
|
-
if (!(isOwnScan && !needsBuffering && !select.distinct) && (select.limit !== undefined || select.offset)) {
|
|
417
|
+
if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length) && (select.limit !== undefined || select.offset)) {
|
|
350
418
|
if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
|
|
351
419
|
plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
|
|
352
420
|
}
|
|
@@ -837,27 +905,34 @@ function collectConjuncts(node, out) {
|
|
|
837
905
|
}
|
|
838
906
|
|
|
839
907
|
/**
|
|
840
|
-
* Push
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
* pushdown. Safe ON terms can move even when WHERE must remain above the
|
|
844
|
-
* join, since ON already excludes those pairs before WHERE is evaluated.
|
|
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.
|
|
845
911
|
*
|
|
846
912
|
* @param {QueryPlan} plan
|
|
847
913
|
* @param {ExprNode | undefined} where
|
|
914
|
+
* @param {SelectStatement} select
|
|
848
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
|
|
849
918
|
* @returns {ExprNode | undefined}
|
|
850
919
|
*/
|
|
851
|
-
function pushJoinPredicates(plan, where, tables) {
|
|
852
|
-
if (!tables || plan.type !== 'HashJoin'
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
861
936
|
|
|
862
937
|
/**
|
|
863
938
|
* @param {ExprNode} expr
|
|
@@ -865,67 +940,185 @@ function pushJoinPredicates(plan, where, tables) {
|
|
|
865
940
|
*/
|
|
866
941
|
function owner(expr) {
|
|
867
942
|
if (expr.type === 'literal') return 'literal'
|
|
868
|
-
if (expr.type !== 'identifier'
|
|
869
|
-
const
|
|
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
|
|
870
946
|
if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
|
|
871
|
-
return index
|
|
947
|
+
return index < leftSources.length ? 'left' : 'right'
|
|
872
948
|
}
|
|
873
949
|
|
|
874
|
-
//
|
|
875
|
-
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
|
+
|
|
876
954
|
/**
|
|
877
|
-
* @param {ExprNode
|
|
878
|
-
* @returns {
|
|
955
|
+
* @param {ExprNode} expr
|
|
956
|
+
* @returns {'left' | 'right' | 'literal' | 'unsafe'}
|
|
879
957
|
*/
|
|
880
|
-
function
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
958
|
+
function expressionOwner(expr) {
|
|
959
|
+
if (!expr) return 'literal'
|
|
960
|
+
if (expr.type === 'identifier' || expr.type === 'literal') return owner(expr)
|
|
961
|
+
/** @type {ExprNode[]} */
|
|
962
|
+
let children
|
|
963
|
+
if (expr.type === 'binary') children = [expr.left, expr.right]
|
|
964
|
+
else if (expr.type === 'unary') children = [expr.argument]
|
|
965
|
+
else if (expr.type === 'function' && !expr.filter) children = expr.args
|
|
966
|
+
else if (expr.type === 'cast') children = [expr.expr]
|
|
967
|
+
else if (expr.type === 'in valuelist') children = [expr.expr, ...expr.values]
|
|
968
|
+
else if (expr.type === 'subscript') children = [expr.expr, expr.index]
|
|
969
|
+
else if (expr.type === 'case') children = [expr.caseExpr, ...expr.whenClauses.flatMap(w => [w.condition, w.result]), expr.elseResult]
|
|
970
|
+
else return 'unsafe'
|
|
971
|
+
/** @type {'left' | 'right' | 'literal'} */
|
|
972
|
+
let target = 'literal'
|
|
973
|
+
for (const child of children) {
|
|
974
|
+
const side = expressionOwner(child)
|
|
975
|
+
if (side === 'unsafe' || side !== 'literal' && target !== 'literal' && side !== target) return 'unsafe'
|
|
976
|
+
if (side !== 'literal') target = side
|
|
890
977
|
}
|
|
891
|
-
return
|
|
978
|
+
return target
|
|
892
979
|
}
|
|
893
980
|
|
|
981
|
+
/** @type {ExprNode[]} */
|
|
982
|
+
const whereConjuncts = []
|
|
983
|
+
if (where) collectConjuncts(where, whereConjuncts)
|
|
984
|
+
/**
|
|
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}
|
|
989
|
+
*/
|
|
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])
|
|
996
|
+
})
|
|
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
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const { joinType } = plan
|
|
1010
|
+
/** @type {ExprNode | undefined} */
|
|
1011
|
+
let leftWhere
|
|
1012
|
+
/** @type {ExprNode | undefined} */
|
|
1013
|
+
let rightWhere
|
|
894
1014
|
/**
|
|
895
1015
|
* @param {ExprNode[]} conjuncts
|
|
896
|
-
* @param {
|
|
1016
|
+
* @param {boolean} on
|
|
897
1017
|
* @returns {ExprNode | undefined}
|
|
898
1018
|
*/
|
|
899
|
-
function push(conjuncts,
|
|
1019
|
+
function push(conjuncts, on) {
|
|
900
1020
|
/** @type {ExprNode | undefined} */
|
|
901
1021
|
let residual
|
|
902
|
-
for (
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
}
|
|
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)
|
|
910
1029
|
}
|
|
911
1030
|
return residual
|
|
912
1031
|
}
|
|
913
1032
|
|
|
914
1033
|
/** @type {ExprNode[]} */
|
|
915
1034
|
const onConjuncts = []
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
+
}
|
|
1063
|
+
|
|
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
|
|
929
1122
|
}
|
|
930
1123
|
|
|
931
1124
|
/**
|
|
@@ -1163,3 +1356,54 @@ function isAllCountStar(columns) {
|
|
|
1163
1356
|
!col.expr.filter
|
|
1164
1357
|
)
|
|
1165
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
|
@@ -11,6 +11,7 @@ export type QueryPlan =
|
|
|
11
11
|
| LimitNode
|
|
12
12
|
| HashAggregateNode
|
|
13
13
|
| ScalarAggregateNode
|
|
14
|
+
| SemiJoinNode
|
|
14
15
|
| HashJoinNode
|
|
15
16
|
| NestedLoopJoinNode
|
|
16
17
|
| PositionalJoinNode
|
|
@@ -100,6 +101,17 @@ export interface ScalarAggregateNode {
|
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
// Join nodes
|
|
104
|
+
// WHERE IN / NOT IN: retain left rows using membership in the right output.
|
|
105
|
+
export interface SemiJoinNode {
|
|
106
|
+
type: 'SemiJoin'
|
|
107
|
+
left: QueryPlan
|
|
108
|
+
right: QueryPlan
|
|
109
|
+
expr: ExprNode
|
|
110
|
+
// EXISTS equality keys; NOT uses anti-join rather than NOT IN null semantics.
|
|
111
|
+
keys?: ExprNode[]
|
|
112
|
+
not: boolean
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
export interface HashJoinNode {
|
|
104
116
|
type: 'HashJoin'
|
|
105
117
|
joinType: JoinType
|
|
@@ -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' },
|