squirreling 0.16.11 → 0.16.13

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.11",
3
+ "version": "0.16.13",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -5,6 +5,7 @@ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumu
5
5
  import { executePlan, executeScan, selectColumnNames } from './execute.js'
6
6
  import { foldEvaluatedRows } from './fold.js'
7
7
  import { normalizeScanColumnResult } from './scanColumn.js'
8
+ import { scanFilter } from './scanFilter.js'
8
9
  import { sortEntriesByTerms } from './sort.js'
9
10
  import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
10
11
  import { keyify } from './utils.js'
@@ -289,10 +290,12 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
289
290
  // Child must be a direct table scan
290
291
  if (plan.child.type !== 'Scan') return
291
292
  const scanNode = plan.child
292
- const { limit, offset, where } = scanNode.hints
293
+ const { limit, offset } = scanNode.hints
293
294
 
294
295
  const table = tables[scanNode.table]
295
296
  if (!table?.scanColumn) return
297
+ const { where, residual } = scanFilter(scanNode, dataSourceColumns(table))
298
+ if (residual) return
296
299
 
297
300
  // COUNT(*) needs a physical column whose filtered chunk lengths can be
298
301
  // counted. Prefer a predicate/projection column, then any table column.
@@ -14,6 +14,7 @@ import { distinctBatches, filterBatches, limitBatches, projectExpressionBatches
14
14
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
15
15
  import { referencesRowScope } from './rowScope.js'
16
16
  import { normalizeScanColumnResult } from './scanColumn.js'
17
+ import { scanFilter } from './scanFilter.js'
17
18
  import { executeSort } from './sort.js'
18
19
  import { executeSemiJoin } from './semiJoin.js'
19
20
  import { addBounds, minBounds, stableRowKey } from './utils.js'
@@ -317,35 +318,51 @@ export function executeScan(plan, context, existingColumnResult) {
317
318
  const { tables, signal } = context
318
319
  const table = validateTable({ ...plan, tables })
319
320
  validateScan({ ...plan, tables })
320
- const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
321
+ const { where, residual } = scanFilter(plan, dataSourceColumns(table))
322
+ const hints = { ...plan.hints, where }
323
+ if (residual) {
324
+ // Keep range and Top-K outside the scan until the engine residual runs.
325
+ delete hints.limit
326
+ delete hints.offset
327
+ }
328
+ const hasLimitOffset = hints.limit !== undefined || hints.offset // 0 offset is noop
321
329
  const scanContext = { ...context, scope: [plan.alias ?? plan.table] }
322
330
 
323
331
  if (!existingColumnResult && table.prepareScan && table.schema) {
324
- const prepared = table.prepareScan(scanRequest(plan, table.schema, scanContext))
325
- return executePreparedScan({ plan, prepared, context: scanContext, table })
332
+ // Derive column demands from the original predicate, including fields
333
+ // needed by engine residuals, but send only the source-local predicate.
334
+ const request = scanRequest(plan, table.schema, scanContext)
335
+ request.filter = where
336
+ if (residual) {
337
+ delete request.limit
338
+ delete request.offset
339
+ delete request.topK
340
+ }
341
+ const prepared = table.prepareScan(request)
342
+ return finish(executePreparedScan({ plan: { ...plan, hints }, prepared, context: scanContext, table }))
326
343
  }
327
344
 
328
345
  // Fast path: single column scan. As with scan(), hints the source did not
329
346
  // apply are handled by the engine over the returned column values.
330
- const scanColumnOptions = plan.hints.columns?.length === 1
331
- ? plan.hints.where
347
+ const scanColumnOptions = hints.columns?.length === 1
348
+ ? hints.where
332
349
  // Do not push a filtered range until WHERE is known to be applied: an
333
350
  // older source may ignore WHERE but eagerly apply LIMIT/OFFSET.
334
- ? { column: plan.hints.columns[0], where: plan.hints.where, signal }
335
- : { column: plan.hints.columns[0], ...plan.hints, signal }
351
+ ? { column: hints.columns[0], where: hints.where, signal }
352
+ : { column: hints.columns[0], ...hints, signal }
336
353
  : undefined
337
354
  const columnResult = existingColumnResult ?? (table.scanColumn && scanColumnOptions
338
355
  ? normalizeScanColumnResult(table.scanColumn(scanColumnOptions), scanColumnOptions)
339
356
  : undefined)
340
357
  if (columnResult && scanColumnOptions) {
341
- const column = plan.hints.columns[0]
342
- const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
358
+ const column = hints.columns[0]
359
+ const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
343
360
  const columns = [column]
344
- const appliedLimitOffset = plan.hints.where
361
+ const appliedLimitOffset = hints.where
345
362
  ? false
346
363
  : columnResult.appliedLimitOffset
347
- const residualFilter = plan.hints.where && !columnResult.appliedWhere
348
- ? compileBatchExpression(plan.hints.where, columns)
364
+ const residualFilter = hints.where && !columnResult.appliedWhere
365
+ ? compileBatchExpression(hints.where, columns)
349
366
  : undefined
350
367
 
351
368
  /** @returns {AsyncIterable<AsyncBatch>} */
@@ -353,28 +370,28 @@ export function executeScan(plan, context, existingColumnResult) {
353
370
  /** @type {AsyncIterable<AsyncBatch>} */
354
371
  let batches = columnBatches(columnResult.chunks(), signal)
355
372
  if (residualFilter) {
356
- const targetRows = plan.hints.limit === undefined
373
+ const targetRows = hints.limit === undefined
357
374
  ? undefined
358
- : plan.hints.limit + (plan.hints.offset ?? 0)
375
+ : hints.limit + (hints.offset ?? 0)
359
376
  batches = filterBatches(batches, residualFilter, signal, targetRows)
360
377
  }
361
378
  if (!appliedLimitOffset && hasLimitOffset) {
362
- batches = limitBatches(batches, plan.hints.limit, plan.hints.offset, signal)
379
+ batches = limitBatches(batches, hints.limit, hints.offset, signal)
363
380
  }
364
381
  return batches
365
382
  }
366
383
 
367
- const canUseBatches = !plan.hints.where || columnResult.appliedWhere || residualFilter !== undefined
384
+ const canUseBatches = !hints.where || columnResult.appliedWhere || residualFilter !== undefined
368
385
  if (canUseBatches) {
369
- return batchResult({
386
+ return finish(batchResult({
370
387
  columns,
371
- numRows: plan.hints.where ? undefined : scanRows,
388
+ numRows: hints.where ? undefined : scanRows,
372
389
  maxRows: scanRows,
373
390
  batches: makeBatches,
374
391
  signal,
375
- })
392
+ }))
376
393
  }
377
- return {
394
+ return finish({
378
395
  columns,
379
396
  numRows: undefined,
380
397
  maxRows: scanRows,
@@ -395,10 +412,10 @@ export function executeScan(plan, context, existingColumnResult) {
395
412
  }
396
413
  })()
397
414
 
398
- result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
415
+ result = filterRows(result, hints.where, scanContext, hints.limit)
399
416
  // Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
400
417
  if (!appliedLimitOffset && hasLimitOffset) {
401
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
418
+ result = limitRows(result, hints.limit, hints.offset, signal)
402
419
  }
403
420
 
404
421
  yield* result
@@ -406,7 +423,7 @@ export function executeScan(plan, context, existingColumnResult) {
406
423
  // the abort so a truncated scan is not mistaken for a complete one
407
424
  signal?.throwIfAborted()
408
425
  },
409
- }
426
+ })
410
427
  }
411
428
 
412
429
  if (!table.scan) {
@@ -414,30 +431,30 @@ export function executeScan(plan, context, existingColumnResult) {
414
431
  }
415
432
 
416
433
  // do the scan
417
- const scanResult = table.scan({ ...plan.hints, signal })
434
+ const scanResult = table.scan({ ...hints, signal })
418
435
  const { appliedWhere, appliedLimitOffset } = scanResult
419
436
 
420
437
  // Applied limit/offset without applied where is invalid
421
- if (!appliedWhere && appliedLimitOffset && plan.hints.where && hasLimitOffset) {
438
+ if (!appliedWhere && appliedLimitOffset && hints.where && hasLimitOffset) {
422
439
  throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
423
440
  }
424
441
 
425
- const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
426
- return {
427
- columns: plan.hints.columns ?? dataSourceColumns(table),
428
- numRows: !plan.hints.where ? scanRows : undefined,
442
+ const scanRows = computeScanRows(table.numRows, hints.limit, hints.offset)
443
+ return finish({
444
+ columns: hints.columns ?? dataSourceColumns(table),
445
+ numRows: !hints.where ? scanRows : undefined,
429
446
  maxRows: scanRows,
430
447
  async *rows() {
431
448
  let result = scanResult.rows()
432
449
 
433
450
  // Apply WHERE if data source did not
434
- if (!appliedWhere && plan.hints.where) {
435
- result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
451
+ if (!appliedWhere && hints.where) {
452
+ result = filterRows(result, hints.where, scanContext, hints.limit)
436
453
  }
437
454
 
438
455
  // Apply LIMIT/OFFSET if data source did not
439
456
  if (!appliedLimitOffset && hasLimitOffset) {
440
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
457
+ result = limitRows(result, hints.limit, hints.offset, signal)
441
458
  }
442
459
 
443
460
  yield* result
@@ -446,6 +463,24 @@ export function executeScan(plan, context, existingColumnResult) {
446
463
  // the abort so a truncated scan is not mistaken for a complete one
447
464
  signal?.throwIfAborted()
448
465
  },
466
+ })
467
+
468
+ /**
469
+ * Apply engine-only predicates and ranges after any source execution path.
470
+ * @param {QueryResults} source
471
+ * @returns {QueryResults}
472
+ */
473
+ function finish(source) {
474
+ if (!residual) return source
475
+ const { limit, offset } = plan.hints
476
+ return {
477
+ columns: source.columns,
478
+ maxRows: computeScanRows(source.maxRows, limit, offset),
479
+ async *rows() {
480
+ const filtered = filterRows(source.rows(), residual, scanContext, limit)
481
+ yield* limitRows(filtered, limit, offset, signal)
482
+ },
483
+ }
449
484
  }
450
485
  }
451
486
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @import { ExprNode } from '../types.js'
3
+ * @import { ScanNode } from '../plan/types.js'
4
+ */
5
+
6
+ /**
7
+ * Translate local conjuncts into physical column names. Keep subqueries and
8
+ * references outside this scan in the engine. Match scope names exactly, as
9
+ * expression evaluation does, including quoted identifiers and case.
10
+ *
11
+ * @param {ScanNode} plan
12
+ * @param {string[]} columns
13
+ * @returns {{ where?: ExprNode, residual?: ExprNode }}
14
+ */
15
+ export function scanFilter(plan, columns) {
16
+ const scope = plan.alias ?? plan.table
17
+
18
+ /**
19
+ * @param {ExprNode} node
20
+ * @returns {{ where?: ExprNode, residual?: ExprNode }}
21
+ */
22
+ function split(node) {
23
+ if (!node) return {}
24
+ if (node.type === 'binary' && node.op === 'AND') {
25
+ const left = split(node.left)
26
+ const right = split(node.right)
27
+ return {
28
+ where: combine(left.where, right.where),
29
+ residual: combine(left.residual, right.residual),
30
+ }
31
+ }
32
+ let safe = true
33
+ /**
34
+ * @param {ExprNode} expr
35
+ * @returns {ExprNode}
36
+ */
37
+ function rewrite(expr) {
38
+ if (!expr) return expr
39
+ if (expr.type === 'identifier') {
40
+ if (expr.prefix && expr.prefix !== scope || !columns.includes(expr.name) ||
41
+ expr.prefix && columns.includes(`${expr.prefix}.${expr.name}`)) {
42
+ safe = false
43
+ return expr
44
+ }
45
+ if (!expr.prefix) return expr
46
+ const bare = { ...expr }
47
+ delete bare.prefix
48
+ return bare
49
+ }
50
+ if (expr.type === 'literal' || expr.type === 'interval') return expr
51
+ if (expr.type === 'binary') return { ...expr, left: rewrite(expr.left), right: rewrite(expr.right) }
52
+ if (expr.type === 'unary') return { ...expr, argument: rewrite(expr.argument) }
53
+ if (expr.type === 'cast') return { ...expr, expr: rewrite(expr.expr) }
54
+ if (expr.type === 'function') return { ...expr, args: expr.args.map(rewrite), ...expr.filter ? { filter: rewrite(expr.filter) } : {} }
55
+ if (expr.type === 'in valuelist') return { ...expr, expr: rewrite(expr.expr), values: expr.values.map(rewrite) }
56
+ if (expr.type === 'subscript') return { ...expr, expr: rewrite(expr.expr), index: rewrite(expr.index) }
57
+ if (expr.type === 'case') return {
58
+ ...expr,
59
+ caseExpr: rewrite(expr.caseExpr),
60
+ whenClauses: expr.whenClauses.map(w => ({ ...w, condition: rewrite(w.condition), result: rewrite(w.result) })),
61
+ elseResult: rewrite(expr.elseResult),
62
+ }
63
+ safe = false
64
+ return expr
65
+ }
66
+ const where = rewrite(node)
67
+ return safe ? { where } : { residual: node }
68
+ }
69
+ return split(plan.hints.where)
70
+ }
71
+
72
+ /**
73
+ * @param {ExprNode} left
74
+ * @param {ExprNode} right
75
+ * @returns {ExprNode}
76
+ */
77
+ function combine(left, right) {
78
+ if (!left) return right
79
+ if (!right) return left
80
+ return { type: 'binary', op: 'AND', left, right, positionStart: left.positionStart, positionEnd: right.positionEnd }
81
+ }
@@ -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
@@ -145,6 +145,7 @@ export interface ScanTopK {
145
145
  export interface ScanRequest {
146
146
  columns: readonly ColumnDemand[]
147
147
  topK?: ScanTopK
148
+ /** Source-local predicate: identifiers use bare physical column names. */
148
149
  filter?: ExprNode
149
150
  limit?: number
150
151
  offset?: number
@@ -271,7 +272,8 @@ export interface ScanResults {
271
272
  */
272
273
  export interface ScanOptions {
273
274
  columns?: string[] // columns needed (undefined means all columns)
274
- where?: ExprNode // where clause
275
+ /** Source-local predicate: identifiers use bare physical column names. */
276
+ where?: ExprNode
275
277
  // important: only apply limit/offset if where is fully applied by the data source
276
278
  // otherwise, the data source must return at least enough rows to ensure the engine
277
279
  // can apply limit/offset correctly after filtering
@@ -285,6 +287,7 @@ export interface ScanOptions {
285
287
  */
286
288
  export interface ScanColumnOptions {
287
289
  column: string
290
+ /** Source-local predicate: identifiers use bare physical column names. */
288
291
  where?: ExprNode
289
292
  limit?: number
290
293
  offset?: number
@@ -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
+ }