squirreling 0.16.10 → 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 +1 -1
- package/src/execute/join.js +4 -4
- 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/validation/functions.js +1 -1
package/package.json
CHANGED
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
|
}
|
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
|
@@ -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' },
|