squirreling 0.16.12 → 0.16.14

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.12",
3
+ "version": "0.16.14",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -43,7 +43,7 @@
43
43
  "@types/node": "26.6.5",
44
44
  "@vitest/coverage-v8": "5.0.5",
45
45
  "eslint": "10.13.0",
46
- "eslint-plugin-jsdoc": "65.4.0",
46
+ "eslint-plugin-jsdoc": "65.4.1",
47
47
  "globals": "17.13.0",
48
48
  "typescript": "7.0.2",
49
49
  "vitest": "5.0.5"
@@ -193,6 +193,18 @@ export function executeHashAggregate(plan, context) {
193
193
  * @returns {QueryResults}
194
194
  */
195
195
  export function executeScalarAggregate(plan, context) {
196
+ if (context.deferScans && plan.child.type === 'Scan') {
197
+ const child = executeScan(plan.child, context)
198
+ return {
199
+ columns: selectColumnNames(plan.columns, child.columns),
200
+ numRows: plan.having ? undefined : 1,
201
+ maxRows: 1,
202
+ async *rows() {
203
+ context.signal?.throwIfAborted()
204
+ yield* executeScalarAggregate(plan, { ...context, deferScans: false }).rows()
205
+ },
206
+ }
207
+ }
196
208
  // Fast path: use scanColumn when available
197
209
  const columnScan = tryColumnScanAggregate(plan, context)
198
210
  if (columnScan?.rows) {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @import { ExprNode, SqlPrimitive } from '../types.js'
3
+ * @import { QueryPlan } from '../plan/types.js'
4
+ */
5
+
6
+ // Bound filter memory and match the session-index pruning limit.
7
+ const MAX_DYNAMIC_FILTER_KEYS = 256
8
+
9
+ /**
10
+ * Bounded, per-column key sets. Once a column exceeds the cap, release it and
11
+ * stop collecting that column. Unsupported values disable that column too.
12
+ *
13
+ * @param {number} count
14
+ * @param {boolean} [hash] - preserve the hash join's serialized key equivalences
15
+ * @returns {{ add(values: SqlPrimitive[]): void, apply(plan: QueryPlan, keys: ExprNode[]): QueryPlan }}
16
+ */
17
+ export function dynamicFilter(count, hash = false) {
18
+ /** @type {(Set<SqlPrimitive> | undefined)[]} */
19
+ const sets = Array.from({ length: count }, () => new Set())
20
+ let active = true
21
+ return {
22
+ /**
23
+ * @param {SqlPrimitive[]} values
24
+ * @returns {void}
25
+ */
26
+ add(values) {
27
+ if (!active) return
28
+ // Hash tuple serialization uses delimiters and serializes objects. Do
29
+ // not prune when those encodings can collide across column boundaries.
30
+ if (hash && values.some(value => typeof value === 'object' && value !== null ||
31
+ typeof value === 'string' && (value.includes('|') || ['[', '{', '"'].includes(value[0]) || value === 'null'))) {
32
+ sets.fill(undefined)
33
+ active = false
34
+ return
35
+ }
36
+ for (let i = 0; i < sets.length; i++) {
37
+ const set = sets[i]
38
+ const value = values[i]
39
+ if (!set || value == null) continue
40
+ if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value) ||
41
+ typeof value === 'number' && !Number.isFinite(value)) {
42
+ sets[i] = undefined
43
+ continue
44
+ }
45
+ set.add(value)
46
+ if (hash && count > 1) {
47
+ if (typeof value === 'boolean') set.add(String(value))
48
+ if (value === 'true' || value === 'false') set.add(value === 'true')
49
+ }
50
+ if (set.size > MAX_DYNAMIC_FILTER_KEYS) sets[i] = undefined
51
+ }
52
+ active = sets.some(Boolean)
53
+ },
54
+ /**
55
+ * @param {QueryPlan} plan
56
+ * @param {ExprNode[]} keys
57
+ * @returns {QueryPlan}
58
+ */
59
+ apply(plan, keys) {
60
+ for (let i = 0; i < keys.length; i++) {
61
+ const set = sets[i]
62
+ if (set?.size) plan = pushFilter(plan, keys[i], [...set])
63
+ }
64
+ return plan
65
+ },
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Only traverse operators that preserve the meaning of a physical column.
71
+ * Limits, aggregates, windows and projections are deliberate barriers.
72
+ *
73
+ * @param {QueryPlan} plan
74
+ * @param {ExprNode} key
75
+ * @param {SqlPrimitive[]} values
76
+ * @returns {QueryPlan}
77
+ */
78
+ function pushFilter(plan, key, values) {
79
+ if (key?.type !== 'identifier') return plan
80
+ if (plan.type === 'Scan') {
81
+ if (key.prefix && key.prefix !== (plan.alias ?? plan.table) ||
82
+ plan.hints.limit !== undefined || plan.hints.offset || plan.topK) return plan
83
+ const position = { positionStart: key.positionStart, positionEnd: key.positionEnd }
84
+ /** @type {ExprNode} */
85
+ const filter = {
86
+ type: 'in valuelist', expr: key, ...position,
87
+ values: values.map(value => ({ type: 'literal', value, ...position })),
88
+ }
89
+ /** @type {ExprNode} */
90
+ const where = plan.hints.where
91
+ ? { type: 'binary', op: 'AND', left: plan.hints.where, right: filter, ...position }
92
+ : filter
93
+ return { ...plan, hints: { ...plan.hints, where } }
94
+ }
95
+ if (plan.type === 'Filter' || plan.type === 'Sort' && !plan.topK || plan.type === 'Distinct') {
96
+ return { ...plan, child: pushFilter(plan.child, key, values) }
97
+ }
98
+ if (plan.type === 'HashJoin' && key.prefix) {
99
+ // Restrict only preserved inputs of outer joins; restricting an optional
100
+ // input could turn a formerly matched row into a new null-extended row.
101
+ const left = plan.joinType === 'INNER' || plan.joinType === 'LEFT'
102
+ ? pushFilter(plan.left, key, values) : plan.left
103
+ const right = plan.joinType === 'INNER' || plan.joinType === 'RIGHT'
104
+ ? pushFilter(plan.right, key, values) : plan.right
105
+ return { ...plan, left, right }
106
+ }
107
+ return plan
108
+ }
109
+
110
+ /**
111
+ * Selectivity heuristic when exact filtered cardinalities are unavailable.
112
+ * Count conjuncts so a date plus a LIKE wins over a lone part-type predicate.
113
+ *
114
+ * @param {QueryPlan} plan
115
+ * @returns {number}
116
+ */
117
+ export function filterScore(plan) {
118
+ if (plan.type === 'Scan') return conjuncts(plan.hints.where)
119
+ if (plan.type === 'Filter') return conjuncts(plan.condition) + filterScore(plan.child)
120
+ if (plan.type === 'HashJoin' && plan.joinType === 'INNER') return Math.max(filterScore(plan.left), filterScore(plan.right))
121
+ return 0
122
+ }
123
+
124
+ /**
125
+ * @param {ExprNode} expr
126
+ * @returns {number}
127
+ */
128
+ function conjuncts(expr) {
129
+ return !expr ? 0 : expr.type === 'binary' && expr.op === 'AND'
130
+ ? conjuncts(expr.left) + conjuncts(expr.right) : 1
131
+ }
@@ -1,5 +1,5 @@
1
1
  import { selectedRowCount } from '../backend/batch.js'
2
- import { batchesToRows } from '../backend/batchAdapters.js'
2
+ import { batchesToRows, rowsToBatches } from '../backend/batchAdapters.js'
3
3
  import { dataSourceColumns, memorySource } from '../backend/dataSource.js'
4
4
  import { derivedAlias } from '../expression/alias.js'
5
5
  import { compileBatchExpression } from '../expression/batch.js'
@@ -318,6 +318,37 @@ export function executeScan(plan, context, existingColumnResult) {
318
318
  const { tables, signal } = context
319
319
  const table = validateTable({ ...plan, tables })
320
320
  validateScan({ ...plan, tables })
321
+ if (context.deferScans && !existingColumnResult) {
322
+ const size = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
323
+ const columns = plan.hints.columns ?? dataSourceColumns(table)
324
+ /** @type {QueryResults} */
325
+ const deferred = {
326
+ columns,
327
+ numRows: plan.hints.where ? undefined : size,
328
+ maxRows: size,
329
+ async *rows() {
330
+ context.signal?.throwIfAborted()
331
+ yield* executeScan(plan, { ...context, deferScans: false }).rows()
332
+ },
333
+ }
334
+ // Preserve native batch execution above a deferred input. Legacy fallback
335
+ // remains at the source boundary if a source declines its column path.
336
+ if (table.prepareScan && table.schema || table.scanColumn && plan.hints.columns?.length === 1) {
337
+ deferred.batches = async function* deferredBatches() {
338
+ context.signal?.throwIfAborted()
339
+ const result = executeScan(plan, { ...context, deferScans: false })
340
+ if (result.batches) {
341
+ // Prepared sources may return extra fields or a different order.
342
+ // Align vectors with the metadata exposed before opening the scan.
343
+ const indexes = columns.map(column => result.columns.indexOf(column))
344
+ for await (const batch of result.batches()) {
345
+ yield { ...batch, columns: indexes.map(index => batch.columns[index]) }
346
+ }
347
+ } else yield* rowsToBatches(result.rows(), columns, { signal })
348
+ }
349
+ }
350
+ return deferred
351
+ }
321
352
  const { where, residual } = scanFilter(plan, dataSourceColumns(table))
322
353
  const hints = { ...plan.hints, where }
323
354
  if (residual) {
@@ -1,6 +1,7 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
2
  import { keyify, maxBounds } from './utils.js'
3
3
  import { executePlan } from './execute.js'
4
+ import { dynamicFilter, filterScore } from './dynamicFilter.js'
4
5
  import { yieldToEventLoop } from './yield.js'
5
6
 
6
7
  /**
@@ -222,14 +223,18 @@ export function executePositionalJoin(plan, context) {
222
223
  * @returns {QueryResults}
223
224
  */
224
225
  export function executeHashJoin(plan, context) {
225
- const left = executePlan({ plan: plan.left, context })
226
- const right = executePlan({ plan: plan.right, context })
227
- // Build the hash table on the smaller side when both sizes are known,
228
- // so joining a small table against a large one buffers the small one.
226
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
227
+ const right = executePlan({ plan: plan.right, context: { ...context, deferScans: true } })
228
+ // Prefer exact sizes, then predicate selectivity, then upper bounds.
229
+ // A filtered scan's table size is only an upper bound, not its cardinality.
229
230
  // Swapping reorders output rows, which SQL leaves unspecified.
230
231
  const leftSize = left.numRows ?? left.maxRows
231
232
  const rightSize = right.numRows ?? right.maxRows
232
- const swap = leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
233
+ const swap = left.numRows !== undefined && right.numRows !== undefined
234
+ ? leftSize < rightSize
235
+ : filterScore(plan.left) !== filterScore(plan.right)
236
+ ? filterScore(plan.left) > filterScore(plan.right)
237
+ : leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
233
238
  return {
234
239
  columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
235
240
  async *rows() {
@@ -237,7 +242,7 @@ export function executeHashJoin(plan, context) {
237
242
  const rightTable = plan.rightAlias
238
243
  const { residual } = plan
239
244
  const build = swap ? left : right
240
- const probe = swap ? right : left
245
+ let probe = swap ? right : left
241
246
  const buildKeys = swap ? plan.leftKeys : plan.rightKeys
242
247
  const probeKeys = swap ? plan.rightKeys : plan.leftKeys
243
248
  // Which sides must also emit their unmatched rows
@@ -260,6 +265,7 @@ export function executeHashJoin(plan, context) {
260
265
  // otherwise rows with NULL join keys are released immediately.
261
266
  /** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
262
267
  const hashMap = new Map()
268
+ const filters = dynamicFilter(buildKeys.length, true)
263
269
  /** @type {AsyncRow[] | undefined} */
264
270
  const buildRows = buildOuter ? [] : undefined
265
271
  /** @type {string[]} */
@@ -281,6 +287,7 @@ export function executeHashJoin(plan, context) {
281
287
  // SQL semantics: NULL never equals anything, so a row with any NULL
282
288
  // join key is excluded from the hash table.
283
289
  if (keyValues.some(v => v == null)) continue
290
+ if (!probeOuter) filters.add(keyValues)
284
291
  const key = keyify(...keyValues)
285
292
  let bucket = hashMap.get(key)
286
293
  if (!bucket) {
@@ -295,8 +302,14 @@ export function executeHashJoin(plan, context) {
295
302
  /** @type {Set<AsyncRow> | undefined} */
296
303
  const matchedBuildRows = buildOuter ? new Set() : undefined
297
304
 
305
+ if (!probeOuter && !hashMap.size && !buildOuter) return
306
+ if (!probeOuter && hashMap.size) {
307
+ const filtered = filters.apply(swap ? plan.right : plan.left, probeKeys)
308
+ probe = executePlan({ plan: filtered, context: { ...context, deferScans: true } })
309
+ }
310
+
298
311
  // Probe phase: stream the other side
299
- for await (const probeRow of probe.rows()) {
312
+ for await (const probeRow of !probeOuter && !hashMap.size ? [] : probe.rows()) {
300
313
  context.signal?.throwIfAborted()
301
314
 
302
315
  if (!probeCols) {
@@ -1,5 +1,6 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
2
  import { executePlan } from './execute.js'
3
+ import { dynamicFilter } from './dynamicFilter.js'
3
4
  import { sqlEquals } from './utils.js'
4
5
  import { yieldToEventLoop } from './yield.js'
5
6
 
@@ -19,7 +20,7 @@ import { yieldToEventLoop } from './yield.js'
19
20
  */
20
21
  export function executeSemiJoin(plan, context) {
21
22
  if (plan.keys) return executeExistsJoin(plan, context)
22
- const left = executePlan({ plan: plan.left, context })
23
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
23
24
  return {
24
25
  columns: left.columns,
25
26
  maxRows: left.maxRows,
@@ -28,29 +29,30 @@ export function executeSemiJoin(plan, context) {
28
29
  const values = new Map()
29
30
  let hasNull = false
30
31
  let count = 0
31
- let built = false
32
32
  let rowIndex = 0
33
- for await (const row of left.rows()) {
33
+ const filters = dynamicFilter(1)
34
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
35
+ context.signal?.throwIfAborted()
36
+ if (++count % 4000 === 0) await yieldToEventLoop()
37
+ const value = await inner.cells[inner.columns[0]]()
38
+ if (value == null) hasNull = true
39
+ else {
40
+ const type = typeof value
41
+ let group = values.get(type)
42
+ if (!group) values.set(type, group = new Set())
43
+ group.add(value)
44
+ if (!plan.not) filters.add([value])
45
+ }
46
+ }
47
+ if (!plan.not && !values.size || plan.not && hasNull) return
48
+ const probe = executePlan({
49
+ plan: plan.not ? plan.left : filters.apply(plan.left, [plan.expr]),
50
+ context: { ...context, deferScans: true },
51
+ })
52
+ for await (const row of probe.rows()) {
34
53
  context.signal?.throwIfAborted()
35
54
  if (++rowIndex % 4000 === 0) await yieldToEventLoop()
36
55
  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
56
  const matched = count === 0 ? false : value == null ? null : contains(values, value) ? true : hasNull ? null : false
55
57
  if (matched !== null && matched !== plan.not) yield row
56
58
  }
@@ -87,36 +89,39 @@ function contains(values, value) {
87
89
  * @returns {QueryResults}
88
90
  */
89
91
  function executeExistsJoin(plan, context) {
90
- const left = executePlan({ plan: plan.left, context })
92
+ const left = executePlan({ plan: plan.left, context: { ...context, deferScans: true } })
91
93
  return {
92
94
  columns: left.columns,
93
95
  maxRows: left.maxRows,
94
96
  async *rows() {
95
97
  /** @type {KeyTrie} */
96
98
  const root = { groups: new Map() }
97
- let built = false
98
99
  let count = 0
99
- for await (const row of left.rows()) {
100
+ const filters = dynamicFilter(plan.keys?.length ?? 0)
101
+ for await (const inner of executePlan({ plan: plan.right, context }).rows()) {
100
102
  context.signal?.throwIfAborted()
101
103
  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
104
+ const values = await Promise.all(inner.columns.map(col => inner.cells[col]()))
105
+ if (values.some(value => value == null)) continue
106
+ if (!plan.not) filters.add(values)
107
+ let node = root
108
+ for (const value of values) {
109
+ const type = typeof value
110
+ let group = node.groups.get(type)
111
+ if (!group) node.groups.set(type, group = new Map())
112
+ let child = group.get(value)
113
+ if (!child) group.set(value, child = { groups: new Map() })
114
+ node = child
119
115
  }
116
+ }
117
+ if (!plan.not && !root.groups.size) return
118
+ const probe = executePlan({
119
+ plan: plan.not ? plan.left : filters.apply(plan.left, plan.keys ?? []),
120
+ context: { ...context, deferScans: true },
121
+ })
122
+ for await (const row of probe.rows()) {
123
+ context.signal?.throwIfAborted()
124
+ if (++count % 4000 === 0) await yieldToEventLoop()
120
125
  const values = await Promise.all((plan.keys ?? []).map(node => evaluateExpr({ node, row, context })))
121
126
  const matched = !values.some(value => value == null) && matchesKeys(root, values, 0)
122
127
  if (matched !== plan.not) yield row
@@ -158,10 +158,14 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
158
158
  ? { ...context, outerRow: row, outerAliases: new Set(outerScope) }
159
159
  : context
160
160
  const gen = executeStatement({ query: node.subquery, context: subContext, outerScope }).rows()
161
- const { value } = await gen.next()
162
- gen.return(undefined)
163
- if (!value) return null
164
- return value.cells[value.columns[0]]()
161
+ try {
162
+ const { value } = await gen.next()
163
+ if (!value) return null
164
+ if (!(await gen.next()).done) throw new Error('Scalar subquery returned more than one row')
165
+ return await value.cells[value.columns[0]]()
166
+ } finally {
167
+ await gen.return(undefined)
168
+ }
165
169
  }
166
170
 
167
171
  // Unary operators
package/src/plan/plan.js CHANGED
@@ -2,13 +2,16 @@ import { dataSourceColumns } from '../backend/dataSource.js'
2
2
  import { derivedAlias } from '../expression/alias.js'
3
3
  import { parseSql } from '../parse/parse.js'
4
4
  import { findAggregate } from '../validation/aggregates.js'
5
+ import { findWindow } from '../validation/windows.js'
5
6
  import { ParseError } from '../validation/parseErrors.js'
6
7
  import { ColumnNotFoundError, TableNotFoundError } from '../validation/tables.js'
7
8
  import { validateNoIdentifiers, validateScan, validateTableRefs } from '../validation/tables.js'
8
9
  import { collectColumnsFromExpr, collectScopeColumns, extractColumns, fromAlias, inferSelectSourceColumns, inferStatementColumns, statementScope, tableFunctionColumnNames } from './columns.js'
10
+ import { andPredicate, collectConjuncts } from './predicates.js'
11
+ import { decorrelateExists, decorrelateScalars } from './subqueries.js'
9
12
 
10
13
  /**
11
- * @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, FromSubquery, FromTable, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement, WindowFunctionNode } from '../types.js'
14
+ * @import { AsyncDataSource, DerivedColumn, ExprNode, FromFunction, FromSubquery, FromTable, IdentifierNode, JoinClause, OrderByItem, PlanSqlOptions, ScanOptions, SelectColumn, SelectStatement, SetOperationStatement, Statement } from '../types.js'
12
15
  * @import { HashJoinNode, QueryPlan, SemiJoinNode, TableFunctionNode, WindowSpec } from './types.js'
13
16
  */
14
17
 
@@ -177,7 +180,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
177
180
  const scopeColumns = collectScopeColumns({ select, cteColumns, tables })
178
181
  /** @type {Map<string, ExprNode>} */
179
182
  const aliases = new Map()
180
- const columns = select.columns.map(col => {
183
+ let columns = select.columns.map(col => {
181
184
  if (col.type === 'derived') {
182
185
  validateTableRefs(col.expr, scopeTables, scopeColumns)
183
186
  const expr = resolveAliases(col.expr, aliases)
@@ -193,7 +196,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
193
196
  }
194
197
  return col
195
198
  })
196
- const orderBy = resolveOrderByAliases(select.orderBy, aliases)
199
+ let orderBy = resolveOrderByAliases(select.orderBy, aliases)
197
200
 
198
201
  // Validate qualified references in other clauses
199
202
  validateTableRefs(select.where, scopeTables, scopeColumns)
@@ -298,9 +301,18 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
298
301
  }
299
302
  select = { ...select, where: residual }
300
303
  }
304
+ const scalar = decorrelateScalars({
305
+ select, columns, orderBy, scopeAliases, scopeColumns, sourceAlias, tables, cteColumns,
306
+ rewriteProjection: !useGrouping && !windows.length,
307
+ planQuery: query => planStatement({ stmt: query, tables, ctePlans, cteColumns }),
308
+ })
309
+ columns = scalar.columns
310
+ select = scalar.select
311
+ orderBy = scalar.orderBy
312
+ const { scalarJoins, scalarWhere } = scalar
301
313
  if (!select.joins.length) {
302
314
  hints.where = select.where
303
- if (!needsBuffering && !select.distinct && !semiJoins.length) {
315
+ if (!needsBuffering && !select.distinct && !semiJoins.length && !scalarWhere) {
304
316
  hints.limit = select.limit
305
317
  hints.offset = select.offset
306
318
  }
@@ -313,7 +325,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
313
325
 
314
326
  // Add JOINs
315
327
  if (select.joins.length) {
316
- plan = planJoin({ left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, cteColumns, perTableColumns, tables, outerScope })
328
+ plan = planJoin({ from: select.from, left: plan, joins: select.joins, leftTable: sourceAlias, ctePlans, cteColumns, perTableColumns, tables, outerScope })
317
329
  }
318
330
 
319
331
  // Whether FROM resolved to our own direct table scan
@@ -330,6 +342,17 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
330
342
 
331
343
  for (const semiJoin of semiJoins) plan = { ...semiJoin, left: plan }
332
344
 
345
+ for (const joinScalar of scalarJoins) plan = joinScalar(plan)
346
+ if (scalarWhere) plan = { type: 'Filter', condition: scalarWhere, child: plan }
347
+
348
+ // Bare stars must only include the original query's relations.
349
+ columns = scalarJoins.length ? columns.flatMap(col =>
350
+ col.type === 'star' && !col.table
351
+ ? [sourceAlias, ...select.joins.map(join => join.alias ?? join.table)]
352
+ .filter(alias => alias !== undefined).map(table => ({ ...col, table }))
353
+ : [col]
354
+ ) : columns
355
+
333
356
  if (useGrouping) {
334
357
  // Aggregation path: GROUP BY or scalar aggregate
335
358
  // HAVING is integrated into aggregate nodes for access to group context
@@ -389,7 +412,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
389
412
  // So the order is: Sort -> Project -> Distinct -> Limit
390
413
 
391
414
  // Fast path for SELECT * without joins
392
- const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length
415
+ const isPassthrough = select.columns.length === 1 && select.columns[0].type === 'star' && !select.joins.length && !scalarJoins.length
393
416
  if (!isPassthrough) {
394
417
  let projectColumns = columns
395
418
  // When parent only needs specific columns, drop unneeded projections
@@ -401,7 +424,9 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
401
424
  // Normalize identifiers to match the child's cell-key layout, so the
402
425
  // Project executor can look up cells by exact name instead of relying
403
426
  // on the evaluator's suffix-search fallback.
404
- const sourceColumns = inferSelectSourceColumns({ select, cteColumns, tables })
427
+ const sourceColumns = inferSelectSourceColumns({ select, cteColumns, tables }).map(name =>
428
+ scalarJoins.length && !name.includes('.') ? `${sourceAlias}.${name}` : name
429
+ )
405
430
  projectColumns = projectColumns.map(col =>
406
431
  col.type === 'derived'
407
432
  ? { ...col, expr: normalizeIdentifiers(col.expr, sourceColumns) }
@@ -414,7 +439,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
414
439
  plan = { type: 'Distinct', child: plan }
415
440
  }
416
441
 
417
- if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length) && (select.limit !== undefined || select.offset)) {
442
+ if (!(isOwnScan && !needsBuffering && !select.distinct && !semiJoins.length && !scalarWhere) && (select.limit !== undefined || select.offset)) {
418
443
  if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
419
444
  plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
420
445
  }
@@ -545,6 +570,7 @@ function planTableFunction(from) {
545
570
 
546
571
  /**
547
572
  * @param {object} options
573
+ * @param {FromTable | FromSubquery | FromFunction | undefined} options.from
548
574
  * @param {QueryPlan} options.left - the left side of the join (FROM or previous joins)
549
575
  * @param {JoinClause[]} options.joins - array of join clauses
550
576
  * @param {string} options.leftTable - name/alias of the left table
@@ -555,9 +581,10 @@ function planTableFunction(from) {
555
581
  * @param {string[]} [options.outerScope] - aliases from an outer query (for correlated subqueries)
556
582
  * @returns {QueryPlan}
557
583
  */
558
- function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumns, tables, outerScope }) {
584
+ function planJoin({ from, left, joins, leftTable, ctePlans, cteColumns, perTableColumns, tables, outerScope }) {
559
585
  let plan = left
560
586
  let currentLeftTable = leftTable
587
+ const sources = [from]
561
588
 
562
589
  // Running scope for lateral UNNEST arg validation — excludes the current
563
590
  // join's own alias (self-reference) and later joins (forward reference).
@@ -569,6 +596,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
569
596
 
570
597
  for (const join of joins) {
571
598
  const rightTable = join.alias ?? join.table
599
+ sources.push(join.fromFunction ?? joinSource(join))
572
600
 
573
601
  // LATERAL table function: right side references left-side columns.
574
602
  if (join.fromFunction) {
@@ -641,7 +669,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
641
669
  // `USING (cols)` desugars to an equi-condition `left.col = right.col` per
642
670
  // column, which routes through the hash-join path like any other ON.
643
671
  const condition = join.on ?? (join.using && buildUsingCondition(join.using, join))
644
- const keys = condition && extractEquiKeys({ condition, leftTable: currentLeftTable, rightTable })
672
+ const keys = condition && extractEquiKeys({ condition, owner: joinColumnOwner(sources, tables, cteColumns), using: !!join.using })
645
673
  if (keys) {
646
674
  /** @type {HashJoinNode} */
647
675
  const hashJoin = {
@@ -853,11 +881,11 @@ function buildUsingCondition(using, pos) {
853
881
  *
854
882
  * @param {object} options
855
883
  * @param {ExprNode} options.condition
856
- * @param {string} options.leftTable
857
- * @param {string} options.rightTable
884
+ * @param {(expr: ExprNode) => string} options.owner
885
+ * @param {boolean} options.using
858
886
  * @returns {{ leftKeys: ExprNode[], rightKeys: ExprNode[], residual?: ExprNode } | undefined}
859
887
  */
860
- function extractEquiKeys({ condition, leftTable, rightTable }) {
888
+ function extractEquiKeys({ condition, owner, using }) {
861
889
  /** @type {ExprNode[]} */
862
890
  const conjuncts = []
863
891
  collectConjuncts(condition, conjuncts)
@@ -868,7 +896,7 @@ function extractEquiKeys({ condition, leftTable, rightTable }) {
868
896
  /** @type {ExprNode[]} */
869
897
  const residuals = []
870
898
  for (const conjunct of conjuncts) {
871
- const eq = classifyEquiConjunct(conjunct, leftTable, rightTable)
899
+ const eq = classifyEquiConjunct(conjunct, owner, using)
872
900
  if (eq) {
873
901
  leftKeys.push(eq.leftKey)
874
902
  rightKeys.push(eq.rightKey)
@@ -887,23 +915,6 @@ function extractEquiKeys({ condition, leftTable, rightTable }) {
887
915
  return residual ? { leftKeys, rightKeys, residual } : { leftKeys, rightKeys }
888
916
  }
889
917
 
890
- /**
891
- * Walks an ON expression, flattening top-level AND conjuncts. Non-AND nodes
892
- * are pushed verbatim. Used to expose individual predicates so equi-keys can
893
- * be lifted out of an AND chain.
894
- *
895
- * @param {ExprNode} node
896
- * @param {ExprNode[]} out
897
- */
898
- function collectConjuncts(node, out) {
899
- if (node.type === 'binary' && node.op === 'AND') {
900
- collectConjuncts(node.left, out)
901
- collectConjuncts(node.right, out)
902
- return
903
- }
904
- out.push(node)
905
- }
906
-
907
918
  /**
908
919
  * Push local ON and WHERE conjuncts according to which side is preserved.
909
920
  * Only recurse through this SELECT's join chain, never through shared CTEs
@@ -926,27 +937,11 @@ function pushJoinPredicates(plan, where, select, tables, cteColumns, inherited =
926
937
  const rightSource = joinSource(last)
927
938
  const sources = [...leftSources, rightSource]
928
939
  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
- : [])
940
+ const owner = joinColumnOwner(sources, tables, cteColumns)
941
+ const columns = sources.map(source => sourceColumns(source, tables, cteColumns))
934
942
  if (new Set(aliases).size !== aliases.length ||
935
943
  columns.some(names => aliases.some(alias => names.includes(alias)))) return where
936
944
 
937
- /**
938
- * @param {ExprNode} expr
939
- * @returns {'left' | 'right' | 'literal' | 'unsafe'}
940
- */
941
- function owner(expr) {
942
- if (expr.type === 'literal') return 'literal'
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
946
- if (index < 0 || !columns[index].includes(expr.name)) return 'unsafe'
947
- return index < leftSources.length ? 'left' : 'right'
948
- }
949
-
950
945
  // Do not change the interpretation of ambiguous or misoriented hash keys.
951
946
  if (plan.type === 'HashJoin' && (plan.leftKeys.some(key => owner(key) !== 'left') ||
952
947
  plan.rightKeys.some(key => owner(key) !== 'right'))) return where
@@ -1122,39 +1117,59 @@ function rejectsNulls(expr, isMissing) {
1122
1117
  }
1123
1118
 
1124
1119
  /**
1125
- * @param {ExprNode | undefined} left
1126
- * @param {ExprNode} right
1127
- * @returns {ExprNode}
1120
+ * @param {FromTable | FromSubquery | FromFunction | undefined} source
1121
+ * @param {Record<string, AsyncDataSource>} [tables]
1122
+ * @param {Map<string, string[]>} [cteColumns]
1123
+ * @returns {string[]}
1124
+ */
1125
+ function sourceColumns(source, tables, cteColumns) {
1126
+ if (source?.type === 'subquery') return inferStatementColumns({ stmt: source.query, tables, cteColumns })
1127
+ if (source?.type === 'function') return tableFunctionColumnNames(source)
1128
+ if (source?.type === 'table') return cteColumns?.get(source.table.toLowerCase()) ?? (tables?.[source.table] ? dataSourceColumns(tables[source.table]) : [])
1129
+ return []
1130
+ }
1131
+
1132
+ /**
1133
+ * Resolve references against every visible source, not the synthetic alias
1134
+ * used to merge rows from earlier joins. Unknown or ambiguous references
1135
+ * remain residual predicates rather than becoming incorrectly oriented keys.
1136
+ *
1137
+ * @param {(FromTable | FromSubquery | FromFunction | undefined)[]} sources
1138
+ * @param {Record<string, AsyncDataSource>} [tables]
1139
+ * @param {Map<string, string[]>} [cteColumns]
1140
+ * @returns {(expr: ExprNode) => 'left' | 'right' | 'literal' | 'unsafe'}
1128
1141
  */
1129
- function andPredicate(left, right) {
1130
- return left
1131
- ? { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
1132
- : right
1142
+ function joinColumnOwner(sources, tables, cteColumns) {
1143
+ const aliases = sources.map(source => fromAlias(source))
1144
+ const columns = sources.map(source => sourceColumns(source, tables, cteColumns))
1145
+ const unsafe = new Set(aliases).size !== aliases.length ||
1146
+ columns.some(names => aliases.some(alias => alias !== undefined && names.includes(alias)))
1147
+ return function owner(expr) {
1148
+ if (expr.type === 'literal') return 'literal'
1149
+ if (unsafe || expr.type !== 'identifier') return 'unsafe'
1150
+ const matches = columns.map((names, i) => names.includes(expr.name) ? i : -1).filter(i => i >= 0)
1151
+ const index = expr.prefix ? aliases.indexOf(expr.prefix) : matches.length === 1 ? matches[0] : -1
1152
+ if (index < 0) return 'unsafe'
1153
+ if (columns[index].length && !columns[index].includes(expr.name)) return 'unsafe'
1154
+ return index === sources.length - 1 ? 'right' : 'left'
1155
+ }
1133
1156
  }
1134
1157
 
1135
1158
  /**
1136
- * Returns the (leftKey, rightKey) pair for an equi conjunct, oriented so
1137
- * leftKey references the left input and rightKey references the right input.
1138
- * Returns undefined when the conjunct is not a `<identifier> = <identifier>`
1139
- * predicate. When the prefixes don't unambiguously identify a side, falls
1140
- * through to the original orientation — matches the prior single-equi
1141
- * behavior so unprefixed columns still produce a hash join.
1159
+ * Orient only equalities whose identifiers belong to opposite inputs.
1160
+ * USING explicitly binds its unqualified identifiers to their respective sides.
1142
1161
  *
1143
1162
  * @param {ExprNode} conjunct
1144
- * @param {string} leftTable
1145
- * @param {string} rightTable
1163
+ * @param {(expr: ExprNode) => string} owner
1164
+ * @param {boolean} using
1146
1165
  * @returns {{ leftKey: ExprNode, rightKey: ExprNode } | undefined}
1147
1166
  */
1148
- function classifyEquiConjunct(conjunct, leftTable, rightTable) {
1167
+ function classifyEquiConjunct(conjunct, owner, using) {
1149
1168
  if (conjunct.type !== 'binary' || conjunct.op !== '=') return undefined
1150
1169
  const { left, right } = conjunct
1151
1170
  if (left.type !== 'identifier' || right.type !== 'identifier') return undefined
1152
- const leftRefsRight = left.prefix === rightTable
1153
- const rightRefsLeft = right.prefix === leftTable
1154
- if (leftRefsRight && rightRefsLeft) {
1155
- return { leftKey: right, rightKey: left }
1156
- }
1157
- return { leftKey: left, rightKey: right }
1171
+ if (using || owner(left) === 'left' && owner(right) === 'right') return { leftKey: left, rightKey: right }
1172
+ if (owner(left) === 'right' && owner(right) === 'left') return { leftKey: right, rightKey: left }
1158
1173
  }
1159
1174
 
1160
1175
  /**
@@ -1297,47 +1312,6 @@ function expectNoWindowFunction(expr, clause) {
1297
1312
  }
1298
1313
  }
1299
1314
 
1300
- /**
1301
- * @param {ExprNode | undefined} expr
1302
- * @returns {WindowFunctionNode | undefined}
1303
- */
1304
- function findWindow(expr) {
1305
- if (!expr) return undefined
1306
- if (expr.type === 'window') return expr
1307
- if (expr.type === 'binary') return findWindow(expr.left) || findWindow(expr.right)
1308
- if (expr.type === 'unary') return findWindow(expr.argument)
1309
- if (expr.type === 'function') {
1310
- for (const arg of expr.args) {
1311
- const found = findWindow(arg)
1312
- if (found) return found
1313
- }
1314
- return undefined
1315
- }
1316
- if (expr.type === 'cast') return findWindow(expr.expr)
1317
- if (expr.type === 'subscript') return findWindow(expr.expr) || findWindow(expr.index)
1318
- if (expr.type === 'in valuelist') {
1319
- const found = findWindow(expr.expr)
1320
- if (found) return found
1321
- for (const val of expr.values) {
1322
- const f = findWindow(val)
1323
- if (f) return f
1324
- }
1325
- return undefined
1326
- }
1327
- if (expr.type === 'case') {
1328
- if (expr.caseExpr) {
1329
- const f = findWindow(expr.caseExpr)
1330
- if (f) return f
1331
- }
1332
- for (const w of expr.whenClauses) {
1333
- const f = findWindow(w.condition) || findWindow(w.result)
1334
- if (f) return f
1335
- }
1336
- if (expr.elseResult) return findWindow(expr.elseResult)
1337
- }
1338
- return undefined
1339
- }
1340
-
1341
1315
  /**
1342
1316
  * Checks if every SELECT column is a plain COUNT(*).
1343
1317
  *
@@ -1356,54 +1330,3 @@ function isAllCountStar(columns) {
1356
1330
  !col.expr.filter
1357
1331
  )
1358
1332
  }
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
- }
@@ -0,0 +1,29 @@
1
+ /** @import { ExprNode } from '../types.js' */
2
+
3
+ /**
4
+ * Walks an ON expression, flattening top-level AND conjuncts. Non-AND nodes
5
+ * are pushed verbatim. Used to expose individual predicates so equi-keys can
6
+ * be lifted out of an AND chain.
7
+ *
8
+ * @param {ExprNode} node
9
+ * @param {ExprNode[]} out
10
+ */
11
+ export function collectConjuncts(node, out) {
12
+ if (node.type === 'binary' && node.op === 'AND') {
13
+ collectConjuncts(node.left, out)
14
+ collectConjuncts(node.right, out)
15
+ return
16
+ }
17
+ out.push(node)
18
+ }
19
+
20
+ /**
21
+ * @param {ExprNode | undefined} left
22
+ * @param {ExprNode} right
23
+ * @returns {ExprNode}
24
+ */
25
+ export function andPredicate(left, right) {
26
+ return left
27
+ ? { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
28
+ : right
29
+ }
@@ -0,0 +1,285 @@
1
+ import { derivedAlias } from '../expression/alias.js'
2
+ import { findAggregate } from '../validation/aggregates.js'
3
+ import { findWindow } from '../validation/windows.js'
4
+ import { collectColumnsFromExpr, statementScope } from './columns.js'
5
+ import { andPredicate, collectConjuncts } from './predicates.js'
6
+
7
+ /**
8
+ * @import { AsyncDataSource, DerivedColumn, ExprNode, IdentifierNode, OrderByItem, SelectColumn, SelectStatement, Statement } from '../types.js'
9
+ * @import { QueryPlan } from './types.js'
10
+ */
11
+
12
+ /**
13
+ * Replaces eligible scalar aggregates with grouped LEFT JOINs and exact
14
+ * empty-group values. Returns correlated WHERE terms for filtering after joins.
15
+ *
16
+ * @param {object} options
17
+ * @param {SelectStatement} options.select
18
+ * @param {SelectColumn[]} options.columns
19
+ * @param {OrderByItem[]} options.orderBy
20
+ * @param {string[]} options.scopeAliases
21
+ * @param {Set<string>} options.scopeColumns
22
+ * @param {string | undefined} options.sourceAlias
23
+ * @param {boolean} options.rewriteProjection
24
+ * @param {(query: SelectStatement) => QueryPlan} options.planQuery
25
+ * @param {Record<string, AsyncDataSource>} [options.tables]
26
+ * @param {Map<string, string[]>} [options.cteColumns]
27
+ * @returns {{ select: SelectStatement, columns: SelectColumn[], orderBy: OrderByItem[], scalarJoins: ((left: QueryPlan) => QueryPlan)[], scalarWhere: ExprNode | undefined }}
28
+ */
29
+ export function decorrelateScalars({ select, columns, orderBy, scopeAliases, scopeColumns, sourceAlias, rewriteProjection, planQuery, tables, cteColumns }) {
30
+ columns = [...columns]
31
+ // Keep original expressions for source column demand; synthetic scalar cells
32
+ // are computed above the scan and must never appear in pushdown hints.
33
+ /** @type {((left: QueryPlan) => QueryPlan)[]} */
34
+ const scalarJoins = []
35
+ /**
36
+ * @param {ExprNode} expr
37
+ * @returns {ExprNode}
38
+ */
39
+ function rewriteScalar(expr) {
40
+ if (expr.type !== 'subquery' || select.limit === 0) return expr
41
+ const query = expr.subquery
42
+ if (query.type !== 'select' || query.columns.length !== 1) return expr
43
+ const column = query.columns[0]
44
+ if (column.type !== 'derived' || !findAggregate(column.expr) || findWindow(column.expr)) return expr
45
+ // Reuse EXISTS' residual-reference check without discarding the aggregate.
46
+ const keys = decorrelateExists(query, scopeAliases, tables, cteColumns, true)
47
+ if (!keys) return expr
48
+ const inner = keys.query.columns.map(col => col.type === 'derived' ? col.expr : column.expr)
49
+ // Hash equality is only equivalent to scalar equality for matching known
50
+ // primitive types. Unknown schemas and coercive comparisons stay per-row.
51
+ if (inner.some((key, i) => {
52
+ if (key.type !== 'identifier') return true
53
+ const type = correlationType(query, key, tables, cteColumns)
54
+ return !type || type !== correlationType(select, keys.left[i], tables, cteColumns)
55
+ })) return expr
56
+ let name = `__scalar_${scalarJoins.length}`
57
+ while ([...scopeColumns, ...scopeAliases].some(col => col === name || col.startsWith(name + '_'))) name += '_'
58
+ const position = { positionStart: expr.positionStart, positionEnd: expr.positionEnd }
59
+ const valueName = name + '_value'
60
+ const keyName = name + '_key'
61
+ const emptyAlias = name + '_empty'
62
+ const innerKey = scalarCorrelationKey(inner)
63
+ const outerKey = scalarCorrelationKey(keys.left)
64
+ /** @type {IdentifierNode} */
65
+ const joinedKey = { ...position, type: 'identifier', prefix: name, name: keyName }
66
+ /** @type {SelectStatement} */
67
+ const grouped = {
68
+ ...keys.query,
69
+ groupBy: [innerKey],
70
+ // Evaluate the original projection before introducing synthetic aliases.
71
+ columns: [
72
+ { ...column, alias: valueName },
73
+ { ...position, type: 'derived', expr: innerKey, alias: keyName },
74
+ ],
75
+ }
76
+ const right = planQuery(grouped)
77
+ const scope = statementScope(query) ?? []
78
+ // A scalar aggregate over zero rows computes the exact empty-group value,
79
+ // including COUNT arithmetic and aggregate expressions with lazy branches.
80
+ /** @type {QueryPlan} */
81
+ const empty = {
82
+ type: 'Subquery', scope,
83
+ child: {
84
+ type: 'ScalarAggregate', columns: [{ ...column, alias: valueName }],
85
+ child: { type: 'Limit', limit: 0, child: { type: 'SingleRow' } },
86
+ },
87
+ }
88
+ /** @type {ExprNode | undefined} */
89
+ let residual
90
+ for (const key of keys.left) {
91
+ // Map treats NaN as equal to itself; SQL equality does not.
92
+ residual = andPredicate(residual, { ...position, type: 'binary', op: '=', left: key, right: key })
93
+ }
94
+ scalarJoins.push(left => ({
95
+ type: 'HashJoin', joinType: 'LEFT', leftAlias: sourceAlias ?? '', rightAlias: name,
96
+ leftKeys: [outerKey], rightKeys: [{ ...joinedKey, prefix: undefined }], residual,
97
+ right: { type: 'Subquery', child: right, scope },
98
+ left: {
99
+ type: 'NestedLoopJoin', joinType: 'INNER', leftAlias: sourceAlias ?? '', rightAlias: emptyAlias, right: empty,
100
+ condition: { ...position, type: 'literal', value: true }, left,
101
+ },
102
+ }))
103
+ return {
104
+ ...position, type: 'case',
105
+ whenClauses: [{
106
+ ...position,
107
+ condition: { ...position, type: 'unary', op: 'IS NULL', argument: joinedKey },
108
+ result: { ...position, type: 'identifier', prefix: emptyAlias, name: valueName },
109
+ }],
110
+ elseResult: { ...position, type: 'identifier', prefix: name, name: valueName },
111
+ }
112
+ }
113
+ // Outer aggregates need group-aware placement, so retain their existing path.
114
+ if (rewriteProjection) {
115
+ for (let i = 0; i < columns.length; i++) {
116
+ const col = columns[i]
117
+ if (col.type === 'derived') {
118
+ const before = scalarJoins.length
119
+ const expr = mapScalarExpressions(col.expr, rewriteScalar)
120
+ if (scalarJoins.length > before) columns[i] = { ...col, alias: col.alias ?? derivedAlias(col.expr), expr }
121
+ }
122
+ }
123
+ orderBy = orderBy.map(term => ({ ...term, expr: mapScalarExpressions(term.expr, rewriteScalar) }))
124
+ }
125
+ /** @type {ExprNode | undefined} */
126
+ let scalarWhere
127
+ if (select.where) {
128
+ /** @type {ExprNode[]} */
129
+ const terms = []
130
+ collectConjuncts(select.where, terms)
131
+ /** @type {ExprNode | undefined} */
132
+ let scanWhere
133
+ for (const term of terms) {
134
+ const before = scalarJoins.length
135
+ const rewritten = mapScalarExpressions(term, rewriteScalar)
136
+ if (scalarJoins.length > before) scalarWhere = andPredicate(scalarWhere, rewritten)
137
+ else scanWhere = andPredicate(scanWhere, term)
138
+ }
139
+ select = { ...select, where: scanWhere }
140
+ }
141
+ return { select, columns, orderBy, scalarJoins, scalarWhere }
142
+ }
143
+
144
+ /**
145
+ * Pulls plain equality correlations out of EXISTS or scalar aggregate queries.
146
+ * Limits, HAVING, and existing grouping stay on the per-outer-row path.
147
+ *
148
+ * @param {Statement} query
149
+ * @param {string[]} outerScope
150
+ * @param {Record<string, AsyncDataSource>} [tables]
151
+ * @param {Map<string, string[]>} [cteColumns]
152
+ * @param {boolean} [aggregate]
153
+ * @returns {{ query: SelectStatement, left: IdentifierNode[] } | undefined}
154
+ */
155
+ export function decorrelateExists(query, outerScope, tables, cteColumns, aggregate = false) {
156
+ if (query.type !== 'select' || query.limit !== undefined || query.offset ||
157
+ query.groupBy.length || query.having ||
158
+ !aggregate && query.columns.some(col => col.type === 'derived' && (findAggregate(col.expr) || col.expr.type === 'window'))) return
159
+ const scope = statementScope(query) ?? []
160
+ /** @type {IdentifierNode[]} */
161
+ const left = []
162
+ /** @type {DerivedColumn[]} */
163
+ const columns = []
164
+ /** @type {ExprNode[]} */
165
+ const conjuncts = []
166
+ if (query.where) collectConjuncts(query.where, conjuncts)
167
+ /** @type {ExprNode | undefined} */
168
+ let where
169
+ for (const term of conjuncts) {
170
+ const previousKeys = columns.length
171
+ if (term.type === 'binary' && term.op === '=' && term.left.type === 'identifier' && term.right.type === 'identifier') {
172
+ for (const [inner, outer] of [[term.left, term.right], [term.right, term.left]]) {
173
+ if (inner.prefix && scope.includes(inner.prefix) && outer.prefix &&
174
+ outerScope.includes(outer.prefix) && !scope.includes(outer.prefix)) {
175
+ left.push(outer)
176
+ columns.push({ type: 'derived', expr: inner, alias: `__exists_key_${columns.length}`, positionStart: inner.positionStart, positionEnd: inner.positionEnd })
177
+ break
178
+ }
179
+ }
180
+ if (columns.length > previousKeys) continue
181
+ }
182
+ where = andPredicate(where, term)
183
+ }
184
+ if (!left.length) return
185
+ // Check every remaining clause, including nested subqueries and projections,
186
+ // before discarding the projection and ordering that EXISTS does not read.
187
+ /** @type {IdentifierNode[]} */
188
+ const remaining = []
189
+ collectColumnsFromExpr({ type: 'subquery', subquery: { ...query, where }, positionStart: query.positionStart, positionEnd: query.positionEnd }, remaining, undefined, {
190
+ tables, cteColumns, outerAliases: new Set(outerScope),
191
+ })
192
+ if (remaining.length) return
193
+ return { query: { ...query, columns, where, distinct: false, orderBy: [] }, left }
194
+ }
195
+
196
+ /**
197
+ * Rewrites scalar expressions without entering a nested lexical query scope.
198
+ * @param {ExprNode} node
199
+ * @param {(node: ExprNode) => ExprNode} rewrite
200
+ * @returns {ExprNode}
201
+ */
202
+ function mapScalarExpressions(node, rewrite) {
203
+ if (!node) return node
204
+ if (node.type === 'unary') {
205
+ return { ...node, argument: mapScalarExpressions(node.argument, rewrite) }
206
+ }
207
+ if (node.type === 'binary') {
208
+ return { ...node, left: mapScalarExpressions(node.left, rewrite), right: mapScalarExpressions(node.right, rewrite) }
209
+ }
210
+ if (node.type === 'function') {
211
+ return { ...node, args: node.args.map(arg => mapScalarExpressions(arg, rewrite)) }
212
+ }
213
+ if (node.type === 'cast') {
214
+ return { ...node, expr: mapScalarExpressions(node.expr, rewrite) }
215
+ }
216
+ if (node.type === 'in valuelist') {
217
+ return {
218
+ ...node,
219
+ expr: mapScalarExpressions(node.expr, rewrite),
220
+ values: node.values.map(v => mapScalarExpressions(v, rewrite)),
221
+ }
222
+ }
223
+ if (node.type === 'subscript') {
224
+ return {
225
+ ...node,
226
+ expr: mapScalarExpressions(node.expr, rewrite),
227
+ index: mapScalarExpressions(node.index, rewrite),
228
+ }
229
+ }
230
+ if (node.type === 'case') {
231
+ return {
232
+ ...node,
233
+ caseExpr: mapScalarExpressions(node.caseExpr, rewrite),
234
+ whenClauses: node.whenClauses.map(w => ({
235
+ ...w,
236
+ condition: mapScalarExpressions(w.condition, rewrite),
237
+ result: mapScalarExpressions(w.result, rewrite),
238
+ })),
239
+ elseResult: mapScalarExpressions(node.elseResult, rewrite),
240
+ }
241
+ }
242
+ return rewrite(node)
243
+ }
244
+
245
+ /**
246
+ * Looks up a correlation column's declared primitive type in its lexical scope.
247
+ * Derived relations and CTEs need type propagation before they can use this rewrite.
248
+ * @param {SelectStatement} query
249
+ * @param {IdentifierNode} key
250
+ * @param {Record<string, AsyncDataSource>} [tables]
251
+ * @param {Map<string, string[]>} [cteColumns]
252
+ * @returns {string | undefined}
253
+ */
254
+ function correlationType(query, key, tables, cteColumns) {
255
+ const { from } = query
256
+ const table = from?.type === 'table' && (from.alias ?? from.table) === key.prefix ? from.table
257
+ : query.joins.find(join => !join.subquery && !join.fromFunction && (join.alias ?? join.table) === key.prefix)?.table
258
+ if (!table || cteColumns?.has(table)) return
259
+ const type = tables?.[table]?.schema?.fields.find(field => field.name === key.name)?.dataType.type
260
+ return type && ['string', 'number', 'bigint', 'boolean'].includes(type) ? type : undefined
261
+ }
262
+
263
+ /**
264
+ * Encodes composite keys with length-prefixed components so ordinary hash
265
+ * grouping and joins cannot confuse tuple boundaries. CONCAT propagates NULL.
266
+ * @param {ExprNode[]} keys
267
+ * @returns {ExprNode}
268
+ */
269
+ function scalarCorrelationKey(keys) {
270
+ if (keys.length === 1) return keys[0]
271
+ const { positionStart, positionEnd } = keys[0]
272
+ const position = { positionStart, positionEnd }
273
+ return {
274
+ ...position, type: 'function', funcName: 'CONCAT',
275
+ args: keys.flatMap(key => {
276
+ /** @type {ExprNode} */
277
+ const text = { ...position, type: 'cast', expr: key, toType: 'TEXT' }
278
+ return [
279
+ /** @type {ExprNode} */ { ...position, type: 'function', funcName: 'LENGTH', args: [text] },
280
+ /** @type {ExprNode} */ { ...position, type: 'literal', value: ':' },
281
+ text,
282
+ ]
283
+ }),
284
+ }
285
+ }
package/src/types.d.ts CHANGED
@@ -199,6 +199,8 @@ export interface PlanSqlOptions {
199
199
 
200
200
  // executePlan(plan, context)
201
201
  export interface ExecuteContext {
202
+ // Delay opening join input scans until their rows are consumed.
203
+ deferScans?: boolean
202
204
  tables: Record<string, AsyncDataSource>
203
205
  functions?: Record<string, UserDefinedFunction>
204
206
  signal?: AbortSignal
@@ -0,0 +1,42 @@
1
+ /** @import { ExprNode, WindowFunctionNode } from '../types.js' */
2
+
3
+ /**
4
+ * @param {ExprNode | undefined} expr
5
+ * @returns {WindowFunctionNode | undefined}
6
+ */
7
+ export function findWindow(expr) {
8
+ if (!expr) return undefined
9
+ if (expr.type === 'window') return expr
10
+ if (expr.type === 'binary') return findWindow(expr.left) || findWindow(expr.right)
11
+ if (expr.type === 'unary') return findWindow(expr.argument)
12
+ if (expr.type === 'function') {
13
+ for (const arg of expr.args) {
14
+ const found = findWindow(arg)
15
+ if (found) return found
16
+ }
17
+ return undefined
18
+ }
19
+ if (expr.type === 'cast') return findWindow(expr.expr)
20
+ if (expr.type === 'subscript') return findWindow(expr.expr) || findWindow(expr.index)
21
+ if (expr.type === 'in valuelist') {
22
+ const found = findWindow(expr.expr)
23
+ if (found) return found
24
+ for (const val of expr.values) {
25
+ const f = findWindow(val)
26
+ if (f) return f
27
+ }
28
+ return undefined
29
+ }
30
+ if (expr.type === 'case') {
31
+ if (expr.caseExpr) {
32
+ const f = findWindow(expr.caseExpr)
33
+ if (f) return f
34
+ }
35
+ for (const w of expr.whenClauses) {
36
+ const f = findWindow(w.condition) || findWindow(w.result)
37
+ if (f) return f
38
+ }
39
+ if (expr.elseResult) return findWindow(expr.elseResult)
40
+ }
41
+ return undefined
42
+ }