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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.16.9",
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.4",
45
- "eslint": "10.12.0",
46
- "eslint-plugin-jsdoc": "65.3.4",
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.4"
49
+ "vitest": "5.0.5"
50
50
  }
51
51
  }
@@ -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') {
@@ -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
  }
@@ -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 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,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 (directFrom && select.joins.length === 1 && !outerScope?.length &&
258
- !ctePlans?.has(select.joins[0].table?.toLowerCase())) {
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 total, table-local comparisons into fresh scans of a direct INNER
841
- * equijoin. Scan execution already enforces hints a source cannot apply.
842
- * Validate ON before moving anything; an unsafe ON also blocks WHERE
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' || plan.joinType !== 'INNER' ||
853
- plan.left.type !== 'Scan' || plan.right.type !== 'Scan') return where
854
- const scans = [plan.left, plan.right]
855
- if (scans.some(scan => scan.hints.where || scan.hints.limit !== undefined || scan.hints.offset)) return where
856
- const aliases = scans.map(scan => scan.alias ?? scan.table)
857
- if (aliases[0] === aliases[1]) return where
858
- const columns = scans.map(scan => dataSourceColumns(tables[scan.table]))
859
- // A prefix that also names a struct column is not an unambiguous alias.
860
- 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
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' || !expr.prefix) return 'unsafe'
869
- const index = aliases.indexOf(expr.prefix)
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 === 0 ? 'left' : 'right'
947
+ return index < leftSources.length ? 'left' : 'right'
872
948
  }
873
949
 
874
- // Avoid changing the evaluation of unresolved or unqualified ON keys.
875
- 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
+
876
954
  /**
877
- * @param {ExprNode[]} conjuncts
878
- * @returns {Array<'left' | 'right' | 'residual'> | undefined}
955
+ * @param {ExprNode} expr
956
+ * @returns {'left' | 'right' | 'literal' | 'unsafe'}
879
957
  */
880
- function classify(conjuncts) {
881
- /** @type {Array<'left' | 'right' | 'residual'>} */
882
- const targets = []
883
- for (const expr of conjuncts) {
884
- if (expr.type !== 'binary' || !['=', '==', '!=', '<>', '<', '<=', '>', '>='].includes(expr.op)) return undefined
885
- const left = owner(expr.left)
886
- const right = owner(expr.right)
887
- if (left === 'unsafe' || right === 'unsafe') return undefined
888
- // Only column/literal terms move; cross-table comparisons remain residual.
889
- targets.push(left !== 'literal' && right === 'literal' ? left : right !== 'literal' && left === 'literal' ? right : 'residual')
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 targets
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 {Array<'left' | 'right' | 'residual'>} targets
1016
+ * @param {boolean} on
897
1017
  * @returns {ExprNode | undefined}
898
1018
  */
899
- function push(conjuncts, targets) {
1019
+ function push(conjuncts, on) {
900
1020
  /** @type {ExprNode | undefined} */
901
1021
  let residual
902
- for (let i = 0; i < conjuncts.length; i++) {
903
- const target = targets[i]
904
- if (target === 'residual') {
905
- residual = andPredicate(residual, conjuncts[i])
906
- } else {
907
- const scan = target === 'left' ? scans[0] : scans[1]
908
- scan.hints = { ...scan.hints, where: andPredicate(scan.hints.where, conjuncts[i]) }
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
- if (plan.residual) collectConjuncts(plan.residual, onConjuncts)
917
- const onTargets = classify(onConjuncts)
918
- if (!onTargets) return where
919
- const onResidual = push(onConjuncts, onTargets)
920
- if (onResidual) plan.residual = onResidual
921
- else delete plan.residual
922
-
923
- if (!where) return undefined
924
- /** @type {ExprNode[]} */
925
- const whereConjuncts = []
926
- collectConjuncts(where, whereConjuncts)
927
- const whereTargets = classify(whereConjuncts)
928
- return whereTargets ? push(whereConjuncts, whereTargets) : where
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
+ }
@@ -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: 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' },