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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.10",
3
+ "version": "0.16.11",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -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 ?? []), innerRow)
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 ?? []), buildRow)
342
+ yield merge(createNullRow(probeCols ?? probe.columns), buildRow)
343
343
  }
344
344
  }
345
345
  }
@@ -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 subResult = executeStatement({ query: node.subquery, context })
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()) {
@@ -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 === 'REGEXP_SUBSTR' || funcName === 'REGEXP_EXTRACT') {
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
- // Create regex
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
- // Create regex
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
- try {
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
+ }
@@ -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 (directFrom && select.joins.length === 1 && !outerScope?.length &&
295
- !ctePlans?.has(select.joins[0].table?.toLowerCase())) {
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 each table-local conjunct into fresh scans of a direct INNER
880
- * equijoin. Scan execution enforces hints a source cannot apply. Unresolved
881
- * and cross-table conjuncts remain residual predicates. Predicate evaluation
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' || plan.joinType !== 'INNER' ||
891
- plan.left.type !== 'Scan' || plan.right.type !== 'Scan') return where
892
- const scans = [plan.left, plan.right]
893
- if (scans.some(scan => scan.hints.where || scan.hints.limit !== undefined || scan.hints.offset)) return where
894
- const aliases = scans.map(scan => scan.alias ?? scan.table)
895
- if (aliases[0] === aliases[1]) return where
896
- const columns = scans.map(scan => dataSourceColumns(tables[scan.table]))
897
- // A prefix that also names a struct column is not an unambiguous alias.
898
- if (columns.some(names => aliases.some(alias => names.includes(alias)))) return where
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 === 0 ? 'left' : 'right'
947
+ return index < leftSources.length ? 'left' : 'right'
911
948
  }
912
949
 
913
- // Avoid changing the evaluation of unresolved or unqualified ON keys.
914
- if (plan.leftKeys.some(key => owner(key) !== 'left') || plan.rightKeys.some(key => owner(key) !== 'right')) return where
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
- * @param {ExprNode[]} conjuncts
947
- * @returns {Array<'left' | 'right' | 'residual'>}
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 classify(conjuncts) {
950
- return conjuncts.map(function target(expr) {
951
- const side = expressionOwner(expr)
952
- return side === 'left' || side === 'right' ? side : 'residual'
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 {Array<'left' | 'right' | 'residual'>} targets
1016
+ * @param {boolean} on
959
1017
  * @returns {ExprNode | undefined}
960
1018
  */
961
- function push(conjuncts, targets) {
1019
+ function push(conjuncts, on) {
962
1020
  /** @type {ExprNode | undefined} */
963
1021
  let residual
964
- for (let i = 0; i < conjuncts.length; i++) {
965
- const target = targets[i]
966
- if (target === 'residual') {
967
- residual = andPredicate(residual, conjuncts[i])
968
- } else {
969
- const scan = target === 'left' ? scans[0] : scans[1]
970
- scan.hints = { ...scan.hints, where: andPredicate(scan.hints.where, conjuncts[i]) }
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
- if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
979
- const onTargets = classify(onConjuncts)
980
- const onResidual = push(onConjuncts, onTargets)
981
- if (onResidual) plan.residual = onResidual
982
- else delete plan.residual
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
- if (!where) return undefined
985
- /** @type {ExprNode[]} */
986
- const whereConjuncts = []
987
- collectConjuncts(where, whereConjuncts)
988
- const whereTargets = classify(whereConjuncts)
989
- return push(whereConjuncts, whereTargets)
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
+ }
@@ -107,6 +107,8 @@ export interface SemiJoinNode {
107
107
  left: QueryPlan
108
108
  right: QueryPlan
109
109
  expr: ExprNode
110
+ // EXISTS equality keys; NOT uses anti-join rather than NOT IN null semantics.
111
+ keys?: ExprNode[]
110
112
  not: boolean
111
113
  }
112
114
 
@@ -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: 4, signature: 'string, pattern[, position[, occurrence]]' },
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' },