squirreling 0.16.12 → 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.12",
3
+ "version": "0.16.13",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -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
+ }
@@ -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
+ }