squirreling 0.15.2 → 0.16.0

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.
@@ -1,11 +1,16 @@
1
- import { memorySource } from '../backend/dataSource.js'
1
+ import { selectedRowCount } from '../backend/batch.js'
2
+ import { batchesToRows } from '../backend/batchAdapters.js'
3
+ import { dataSourceColumns, memorySource } from '../backend/dataSource.js'
2
4
  import { derivedAlias } from '../expression/alias.js'
5
+ import { compileBatchExpression } from '../expression/batch.js'
3
6
  import { evaluateExpr } from '../expression/evaluate.js'
4
7
  import { parseSql } from '../parse/parse.js'
5
8
  import { planSql, planStatement } from '../plan/plan.js'
6
- import { statementScope } from '../plan/columns.js'
9
+ import { collectColumnsFromExpr, statementScope } from '../plan/columns.js'
7
10
  import { validateScan, validateTable } from '../validation/tables.js'
8
11
  import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
12
+ import { batchResult } from './batchResults.js'
13
+ import { distinctBatches, filterBatches, limitBatches, projectExpressionBatches } from './batches.js'
9
14
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
10
15
  import { normalizeScanColumnResult } from './scanColumn.js'
11
16
  import { executeSort } from './sort.js'
@@ -14,7 +19,8 @@ import { executeWindow } from './window.js'
14
19
  import { yieldToEventLoop } from './yield.js'
15
20
 
16
21
  /**
17
- * @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, QueryResults, SelectColumn, SqlPrimitive, Statement } from '../types.js'
22
+ * @import { BatchProjection, CompiledBatchExpression } from '../internalTypes.js'
23
+ * @import { AsyncBatch, AsyncCells, AsyncDataSource, AsyncRow, ColumnDemand, ColumnVector, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, PreparedScan, QueryResults, RelationSchema, ScanRequest, SelectColumn, SqlPrimitive, Statement } from '../types.js'
18
24
  * @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode, TableFunctionNode } from '../plan/types.js'
19
25
  */
20
26
 
@@ -278,6 +284,12 @@ export function executeScan(plan, context, existingColumnResult) {
278
284
  const table = validateTable({ ...plan, tables })
279
285
  validateScan({ ...plan, tables })
280
286
  const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
287
+ const scanContext = { ...context, scope: [plan.alias ?? plan.table] }
288
+
289
+ if (!existingColumnResult && table.prepareScan && table.schema) {
290
+ const prepared = table.prepareScan(scanRequest(plan, table.schema, scanContext))
291
+ return executePreparedScan({ plan, prepared, context: scanContext, table })
292
+ }
281
293
 
282
294
  // Fast path: single column scan. As with scan(), hints the source did not
283
295
  // apply are handled by the engine over the returned column values.
@@ -294,9 +306,43 @@ export function executeScan(plan, context, existingColumnResult) {
294
306
  if (columnResult && scanColumnOptions) {
295
307
  const column = plan.hints.columns[0]
296
308
  const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
309
+ const columns = [column]
310
+ const appliedLimitOffset = plan.hints.where
311
+ ? false
312
+ : columnResult.appliedLimitOffset
313
+ const residualFilter = plan.hints.where && !columnResult.appliedWhere
314
+ ? compileBatchExpression(plan.hints.where, columns)
315
+ : undefined
316
+
317
+ /** @returns {AsyncIterable<AsyncBatch>} */
318
+ function makeBatches() {
319
+ /** @type {AsyncIterable<AsyncBatch>} */
320
+ let batches = columnBatches(columnResult.chunks(), signal)
321
+ if (residualFilter) {
322
+ const targetRows = plan.hints.limit === undefined
323
+ ? undefined
324
+ : plan.hints.limit + (plan.hints.offset ?? 0)
325
+ batches = filterBatches(batches, residualFilter, signal, targetRows)
326
+ }
327
+ if (!appliedLimitOffset && hasLimitOffset) {
328
+ batches = limitBatches(batches, plan.hints.limit, plan.hints.offset, signal)
329
+ }
330
+ return batches
331
+ }
332
+
333
+ const canUseBatches = !plan.hints.where || columnResult.appliedWhere || residualFilter !== undefined
334
+ if (canUseBatches) {
335
+ return batchResult({
336
+ columns,
337
+ numRows: plan.hints.where ? undefined : scanRows,
338
+ maxRows: scanRows,
339
+ batches: makeBatches,
340
+ signal,
341
+ })
342
+ }
297
343
  return {
298
- columns: [column],
299
- numRows: plan.hints.where ? undefined : scanRows,
344
+ columns,
345
+ numRows: undefined,
300
346
  maxRows: scanRows,
301
347
  async *rows() {
302
348
  const columns = [column]
@@ -315,13 +361,8 @@ export function executeScan(plan, context, existingColumnResult) {
315
361
  }
316
362
  })()
317
363
 
318
- if (!columnResult.appliedWhere && plan.hints.where) {
319
- result = filterRows(result, plan.hints.where, context, plan.hints.limit)
320
- }
364
+ result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
321
365
  // Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
322
- const appliedLimitOffset = plan.hints.where
323
- ? false
324
- : columnResult.appliedLimitOffset
325
366
  if (!appliedLimitOffset && hasLimitOffset) {
326
367
  result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
327
368
  }
@@ -334,6 +375,10 @@ export function executeScan(plan, context, existingColumnResult) {
334
375
  }
335
376
  }
336
377
 
378
+ if (!table.scan) {
379
+ throw new Error(`Data source "${plan.table}" does not implement scan()`)
380
+ }
381
+
337
382
  // do the scan
338
383
  const scanResult = table.scan({ ...plan.hints, signal })
339
384
  const { appliedWhere, appliedLimitOffset } = scanResult
@@ -345,7 +390,7 @@ export function executeScan(plan, context, existingColumnResult) {
345
390
 
346
391
  const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
347
392
  return {
348
- columns: plan.hints.columns ?? table.columns,
393
+ columns: plan.hints.columns ?? dataSourceColumns(table),
349
394
  numRows: !plan.hints.where ? scanRows : undefined,
350
395
  maxRows: scanRows,
351
396
  async *rows() {
@@ -353,7 +398,7 @@ export function executeScan(plan, context, existingColumnResult) {
353
398
 
354
399
  // Apply WHERE if data source did not
355
400
  if (!appliedWhere && plan.hints.where) {
356
- result = filterRows(result, plan.hints.where, context, plan.hints.limit)
401
+ result = filterRows(result, plan.hints.where, scanContext, plan.hints.limit)
357
402
  }
358
403
 
359
404
  // Apply LIMIT/OFFSET if data source did not
@@ -370,6 +415,137 @@ export function executeScan(plan, context, existingColumnResult) {
370
415
  }
371
416
  }
372
417
 
418
+ /**
419
+ * Executes a prepared native-batch scan and applies only the residual work
420
+ * reported by the source.
421
+ *
422
+ * @param {Object} options
423
+ * @param {ScanNode} options.plan
424
+ * @param {PreparedScan} options.prepared
425
+ * @param {ExecuteContext} options.context
426
+ * @param {AsyncDataSource} options.table
427
+ * @returns {QueryResults}
428
+ */
429
+ function executePreparedScan({ plan, prepared, context, table }) {
430
+ const { signal } = context
431
+ const { residual, properties, schema } = prepared
432
+ const hasRequestedRange = plan.hints.limit !== undefined || Boolean(plan.hints.offset)
433
+ if (residual.filter && hasRequestedRange && (
434
+ residual.limit !== plan.hints.limit || (residual.offset ?? 0) !== (plan.hints.offset ?? 0)
435
+ )) {
436
+ throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
437
+ }
438
+ const columns = schema.fields.map(function fieldName(field) { return field.name })
439
+ const residualFilter = residual.filter
440
+ ? compileUnscopedBatchExpression(residual.filter, columns, context)
441
+ : undefined
442
+ const canUseBatches = !residual.filter || residualFilter !== undefined
443
+
444
+ /** @returns {AsyncIterable<AsyncBatch>} */
445
+ function makeBatches() {
446
+ /** @type {AsyncIterable<AsyncBatch>} */
447
+ let batches = prepared.batches({ signal })
448
+ if (residualFilter) {
449
+ const targetRows = residual.limit === undefined
450
+ ? undefined
451
+ : residual.limit + (residual.offset ?? 0)
452
+ batches = filterBatches(batches, residualFilter, signal, targetRows)
453
+ }
454
+ if (residual.limit !== undefined || residual.offset) {
455
+ batches = limitBatches(batches, residual.limit, residual.offset, signal)
456
+ }
457
+ return batches
458
+ }
459
+
460
+ const exactRows = properties.exactRows === undefined
461
+ ? computeScanRows(plan.hints.where ? undefined : table.numRows, plan.hints.limit, plan.hints.offset)
462
+ : computeScanRows(properties.exactRows, residual.limit, residual.offset)
463
+ const preparedMaxRows = properties.maxRows ?? properties.exactRows
464
+ const maxRows = preparedMaxRows === undefined
465
+ ? computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
466
+ : computeScanRows(preparedMaxRows, residual.limit, residual.offset)
467
+ const metadata = {
468
+ columns,
469
+ numRows: residual.filter ? undefined : exactRows,
470
+ maxRows,
471
+ }
472
+ if (canUseBatches) {
473
+ return batchResult({ ...metadata, batches: makeBatches, signal })
474
+ }
475
+ return {
476
+ ...metadata,
477
+ async *rows() {
478
+ let result = batchesToRows(prepared.batches({ signal }), columns, signal)
479
+ if (residual.filter) result = filterRows(result, residual.filter, context, residual.limit)
480
+ if (residual.limit !== undefined || residual.offset) {
481
+ result = limitRows(result, residual.limit, residual.offset, signal)
482
+ }
483
+ yield* result
484
+ signal?.throwIfAborted()
485
+ },
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Builds a generic demand schedule from the scan's logical columns. Predicate
491
+ * fields are required in phase zero; remaining output fields stay deferred.
492
+ *
493
+ * @param {ScanNode} plan
494
+ * @param {RelationSchema} schema
495
+ * @param {ExecuteContext} context
496
+ * @returns {ScanRequest}
497
+ */
498
+ function scanRequest(plan, schema, context) {
499
+ const currentScope = context.scope ?? [plan.table]
500
+ /** @type {IdentifierNode[]} */
501
+ const predicateIdentifiers = []
502
+ collectColumnsFromExpr(plan.hints.where, predicateIdentifiers, undefined, {
503
+ cteColumns: context.cteColumns,
504
+ tables: context.tables,
505
+ outerAliases: new Set([...currentScope, ...context.outerAliases ?? []]),
506
+ })
507
+ const predicateNames = new Set()
508
+ for (const identifier of predicateIdentifiers) {
509
+ if (!identifier.prefix) {
510
+ predicateNames.add(identifier.name)
511
+ continue
512
+ }
513
+ if (currentScope.includes(identifier.prefix)) {
514
+ predicateNames.add(identifier.name)
515
+ continue
516
+ }
517
+ if (context.outerAliases?.has(identifier.prefix)) continue
518
+ const baseField = schema.fields.some(function fieldOwnsPrefix(field) {
519
+ return field.name === identifier.prefix
520
+ })
521
+ if (baseField) predicateNames.add(identifier.prefix)
522
+ }
523
+ const requestedNames = plan.hints.columns
524
+ ? [...plan.hints.columns]
525
+ : schema.fields.map(function fieldName(field) { return field.name })
526
+ for (const name of predicateNames) {
527
+ if (!requestedNames.includes(name)) requestedNames.push(name)
528
+ }
529
+ /** @type {ColumnDemand[]} */
530
+ const columns = requestedNames.map(function columnDemand(name) {
531
+ const field = schema.fields.find(function fieldName(candidate) { return candidate.name === name })
532
+ if (!field) throw new Error(`Prepared source schema does not contain column "${name}"`)
533
+ const predicate = predicateNames.has(name)
534
+ return {
535
+ field: field.id,
536
+ phase: predicate ? 0 : 1,
537
+ purpose: predicate ? 'filter' : 'output',
538
+ mode: predicate ? 'required' : 'deferred',
539
+ }
540
+ })
541
+ return {
542
+ columns,
543
+ filter: plan.hints.where,
544
+ limit: plan.hints.limit,
545
+ offset: plan.hints.offset,
546
+ }
547
+ }
548
+
373
549
  /**
374
550
  * Executes a Count node using numRows when available, falling back to scan
375
551
  *
@@ -392,7 +568,24 @@ function executeCount(plan, context) {
392
568
  // Use source numRows if available
393
569
  if (table.numRows !== undefined) return table.numRows
394
570
 
571
+ if (table.prepareScan && table.schema) {
572
+ const prepared = table.prepareScan({ columns: [] })
573
+ if (prepared.properties.exactRows !== undefined) {
574
+ return prepared.properties.exactRows
575
+ }
576
+ let count = 0
577
+ for await (const batch of prepared.batches({ signal })) {
578
+ signal?.throwIfAborted()
579
+ count += selectedRowCount(batch.selection)
580
+ }
581
+ signal?.throwIfAborted()
582
+ return count
583
+ }
584
+
395
585
  // Fall back to counting rows via scan
586
+ if (!table.scan) {
587
+ throw new Error(`Data source "${plan.table}" does not implement scan()`)
588
+ }
396
589
  let count = 0
397
590
  const { rows } = table.scan({ signal })
398
591
  // eslint-disable-next-line no-unused-vars
@@ -512,6 +705,41 @@ async function* limitRows(rows, limit = Infinity, offset = 0, signal) {
512
705
  }
513
706
  }
514
707
 
708
+ /**
709
+ * Compiles a batch expression only when qualified identifiers do not depend
710
+ * on current or outer row scope.
711
+ *
712
+ * @param {ExprNode} expression
713
+ * @param {string[]} columns
714
+ * @param {ExecuteContext} context
715
+ * @returns {CompiledBatchExpression | undefined}
716
+ */
717
+ function compileUnscopedBatchExpression(expression, columns, context) {
718
+ return referencesRowScope(expression, columns, context)
719
+ ? undefined
720
+ : compileBatchExpression(expression, columns)
721
+ }
722
+
723
+ /**
724
+ * Returns whether an expression reads a qualified identifier from row scope.
725
+ *
726
+ * @param {ExprNode} expression
727
+ * @param {string[]} columns
728
+ * @param {ExecuteContext} context
729
+ * @returns {boolean}
730
+ */
731
+ function referencesRowScope(expression, columns, context) {
732
+ /** @type {IdentifierNode[]} */
733
+ const identifiers = []
734
+ collectColumnsFromExpr(expression, identifiers)
735
+ return identifiers.some(function scopedIdentifier(identifier) {
736
+ return Boolean(identifier.prefix && (
737
+ context.outerAliases?.has(identifier.prefix) ||
738
+ context.scope?.includes(identifier.prefix) && columns.includes(identifier.prefix)
739
+ ))
740
+ })
741
+ }
742
+
515
743
  /**
516
744
  * Executes a filter operation (WHERE clause)
517
745
  *
@@ -521,6 +749,22 @@ async function* limitRows(rows, limit = Infinity, offset = 0, signal) {
521
749
  */
522
750
  function executeFilter(plan, context) {
523
751
  const child = executePlan({ plan: plan.child, context })
752
+ const expression = child.batches
753
+ ? compileUnscopedBatchExpression(plan.condition, child.columns, context)
754
+ : undefined
755
+ if (expression && child.batches) {
756
+ const readChildBatches = child.batches
757
+ /** @returns {AsyncIterable<AsyncBatch>} */
758
+ function makeBatches() {
759
+ return filterBatches(readChildBatches(), expression, context.signal)
760
+ }
761
+ return batchResult({
762
+ columns: child.columns,
763
+ maxRows: child.maxRows,
764
+ batches: makeBatches,
765
+ signal: context.signal,
766
+ })
767
+ }
524
768
  return {
525
769
  columns: child.columns,
526
770
  maxRows: child.maxRows,
@@ -539,9 +783,32 @@ function executeProject(plan, context) {
539
783
  const child = executePlan({ plan: plan.child, context })
540
784
  const columns = selectColumnNames(plan.columns, child.columns)
541
785
 
542
- const resolveable = plan.columns.every(col =>
543
- col.type === 'star' || col.type === 'derived' && col.expr.type === 'identifier'
544
- )
786
+ const resolveable = plan.columns.every(function resolvesDirectly(column) {
787
+ if (column.type === 'star') return true
788
+ if (column.expr.type !== 'identifier') return false
789
+ const sourceName = column.expr.prefix
790
+ ? `${column.expr.prefix}.${column.expr.name}`
791
+ : column.expr.name
792
+ return child.columns.includes(sourceName)
793
+ })
794
+
795
+ const projection = child.batches
796
+ ? batchProjection(plan.columns, columns, child.columns, context)
797
+ : undefined
798
+ if (projection && child.batches) {
799
+ const readChildBatches = child.batches
800
+ /** @returns {AsyncIterable<AsyncBatch>} */
801
+ function makeBatches() {
802
+ return projectExpressionBatches(readChildBatches(), projection)
803
+ }
804
+ return batchResult({
805
+ columns,
806
+ numRows: child.numRows,
807
+ maxRows: child.maxRows,
808
+ batches: makeBatches,
809
+ signal: context.signal,
810
+ })
811
+ }
545
812
 
546
813
  return {
547
814
  columns,
@@ -631,6 +898,19 @@ function executeProject(plan, context) {
631
898
  */
632
899
  function executeDistinct(plan, context) {
633
900
  const child = executePlan({ plan: plan.child, context })
901
+ if (child.batches) {
902
+ const readChildBatches = child.batches
903
+ /** @returns {AsyncIterable<AsyncBatch>} */
904
+ function makeBatches() {
905
+ return distinctBatches(readChildBatches(), context.signal)
906
+ }
907
+ return batchResult({
908
+ columns: child.columns,
909
+ maxRows: child.maxRows,
910
+ batches: makeBatches,
911
+ signal: context.signal,
912
+ })
913
+ }
634
914
  return {
635
915
  columns: child.columns,
636
916
  maxRows: child.maxRows,
@@ -689,6 +969,20 @@ function executeDistinct(plan, context) {
689
969
  */
690
970
  function executeLimit(plan, context) {
691
971
  const child = executePlan({ plan: plan.child, context })
972
+ if (child.batches) {
973
+ const readChildBatches = child.batches
974
+ /** @returns {AsyncIterable<AsyncBatch>} */
975
+ function makeBatches() {
976
+ return limitBatches(readChildBatches(), plan.limit, plan.offset, context.signal)
977
+ }
978
+ return batchResult({
979
+ columns: child.columns,
980
+ numRows: computeScanRows(child.numRows, plan.limit, plan.offset),
981
+ maxRows: computeScanRows(child.maxRows, plan.limit, plan.offset),
982
+ batches: makeBatches,
983
+ signal: context.signal,
984
+ })
985
+ }
692
986
  return {
693
987
  columns: child.columns,
694
988
  numRows: computeScanRows(child.numRows, plan.limit, plan.offset),
@@ -697,6 +991,132 @@ function executeLimit(plan, context) {
697
991
  }
698
992
  }
699
993
 
994
+ /**
995
+ * Wraps one-column source chunks as loaded batches without copying their
996
+ * arrays. Each source chunk remains the async scheduling unit.
997
+ *
998
+ * @param {AsyncIterable<ArrayLike<SqlPrimitive>>} chunks
999
+ * @param {AbortSignal} [signal]
1000
+ * @yields {AsyncBatch}
1001
+ */
1002
+ async function* columnBatches(chunks, signal) {
1003
+ for await (const chunk of chunks) {
1004
+ signal?.throwIfAborted()
1005
+ const vector = vectorFromChunk(chunk)
1006
+ /** @type {AsyncBatch} */
1007
+ const batch = {
1008
+ selection: { type: 'all', length: vector.length },
1009
+ columns: [vector],
1010
+ }
1011
+ yield batch
1012
+ }
1013
+ signal?.throwIfAborted()
1014
+ }
1015
+
1016
+ /**
1017
+ * @param {ArrayLike<SqlPrimitive>} chunk
1018
+ * @returns {ColumnVector}
1019
+ */
1020
+ function vectorFromChunk(chunk) {
1021
+ if (Array.isArray(chunk)) {
1022
+ return { type: 'values', values: chunk, length: chunk.length }
1023
+ }
1024
+ if (isNumericArray(chunk)) {
1025
+ return {
1026
+ type: 'typed',
1027
+ values: chunk,
1028
+ length: chunk.length,
1029
+ }
1030
+ }
1031
+ return { type: 'values', values: Array.from(chunk), length: chunk.length }
1032
+ }
1033
+
1034
+ /**
1035
+ * @param {ArrayLike<SqlPrimitive>} values
1036
+ * @returns {values is import('../types.js').NumericArray}
1037
+ */
1038
+ function isNumericArray(values) {
1039
+ return values instanceof Int8Array
1040
+ || values instanceof Uint8Array
1041
+ || values instanceof Uint8ClampedArray
1042
+ || values instanceof Int16Array
1043
+ || values instanceof Uint16Array
1044
+ || values instanceof Int32Array
1045
+ || values instanceof Uint32Array
1046
+ || values instanceof Float32Array
1047
+ || values instanceof Float64Array
1048
+ || values instanceof BigInt64Array
1049
+ || values instanceof BigUint64Array
1050
+ }
1051
+
1052
+ /**
1053
+ * Compiles a projection to direct, constant, or computed batch columns.
1054
+ *
1055
+ * @param {SelectColumn[]} planColumns
1056
+ * @param {string[]} outputColumns
1057
+ * @param {string[]} childColumns
1058
+ * @param {ExecuteContext} context
1059
+ * @returns {BatchProjection[] | undefined}
1060
+ */
1061
+ function batchProjection(planColumns, outputColumns, childColumns, context) {
1062
+ /** @type {BatchProjection[]} */
1063
+ const projections = []
1064
+ for (const column of planColumns) {
1065
+ if (column.type === 'star') {
1066
+ const prefix = column.table ? `${column.table}.` : undefined
1067
+ for (let i = 0; i < childColumns.length; i++) {
1068
+ if (!prefix || childColumns[i].startsWith(prefix)) {
1069
+ projections.push({ type: 'column', columnIndex: i })
1070
+ }
1071
+ }
1072
+ continue
1073
+ }
1074
+
1075
+ if (referencesRowScope(column.expr, childColumns, context)) return undefined
1076
+
1077
+ if (column.expr.type === 'literal') {
1078
+ projections.push({ type: 'constant', value: column.expr.value })
1079
+ continue
1080
+ }
1081
+ if (column.expr.type === 'identifier') {
1082
+ const index = identifierColumnIndex(column.expr, childColumns)
1083
+ if (index !== undefined) {
1084
+ projections.push({ type: 'column', columnIndex: index })
1085
+ continue
1086
+ }
1087
+ const expression = compileBatchExpression(column.expr, childColumns)
1088
+ if (!expression) return undefined
1089
+ projections.push({ type: 'expression', expression })
1090
+ continue
1091
+ }
1092
+ const expression = compileBatchExpression(column.expr, childColumns)
1093
+ if (!expression) return undefined
1094
+ projections.push({ type: 'expression', expression })
1095
+ }
1096
+ if (projections.length !== outputColumns.length) return undefined
1097
+
1098
+ return projections
1099
+ }
1100
+
1101
+ /**
1102
+ * @param {IdentifierNode} identifier
1103
+ * @param {string[]} childColumns
1104
+ * @returns {number | undefined}
1105
+ */
1106
+ function identifierColumnIndex(identifier, childColumns) {
1107
+ const sourceName = identifier.prefix
1108
+ ? `${identifier.prefix}.${identifier.name}`
1109
+ : identifier.name
1110
+ const index = childColumns.lastIndexOf(sourceName)
1111
+ if (index >= 0) return index
1112
+
1113
+ const suffix = `.${identifier.name}`
1114
+ const matches = childColumns
1115
+ .map(function childColumn(name, childIndex) { return { name, childIndex } })
1116
+ .filter(function suffixMatch(candidate) { return candidate.name.endsWith(suffix) })
1117
+ return matches.length === 1 ? matches[0].childIndex : undefined
1118
+ }
1119
+
700
1120
  /**
701
1121
  * Executes a set operation (UNION, INTERSECT, EXCEPT)
702
1122
  *
@@ -706,11 +1126,13 @@ function executeLimit(plan, context) {
706
1126
  */
707
1127
  function executeSetOperation(plan, context) {
708
1128
  const { signal } = context
1129
+ const leftContext = plan.leftScope === undefined ? context : { ...context, scope: plan.leftScope }
1130
+ const rightContext = plan.rightScope === undefined ? context : { ...context, scope: plan.rightScope }
1131
+ const left = executePlan({ plan: plan.left, context: leftContext })
1132
+ const right = executePlan({ plan: plan.right, context: rightContext })
709
1133
 
710
1134
  if (plan.operator === 'UNION') {
711
1135
  if (plan.all) {
712
- const left = executePlan({ plan: plan.left, context })
713
- const right = executePlan({ plan: plan.right, context })
714
1136
  return {
715
1137
  columns: left.columns,
716
1138
  numRows: addBounds(left.numRows, right.numRows),
@@ -722,8 +1144,6 @@ function executeSetOperation(plan, context) {
722
1144
  },
723
1145
  }
724
1146
  } else {
725
- const left = executePlan({ plan: plan.left, context })
726
- const right = executePlan({ plan: plan.right, context })
727
1147
  return {
728
1148
  columns: left.columns,
729
1149
  maxRows: addBounds(left.maxRows, right.maxRows),
@@ -759,8 +1179,6 @@ function executeSetOperation(plan, context) {
759
1179
  }
760
1180
  }
761
1181
  } else if (plan.operator === 'INTERSECT') {
762
- const left = executePlan({ plan: plan.left, context })
763
- const right = executePlan({ plan: plan.right, context })
764
1182
  return {
765
1183
  columns: left.columns,
766
1184
  maxRows: minBounds(left.maxRows, right.maxRows),
@@ -814,8 +1232,6 @@ function executeSetOperation(plan, context) {
814
1232
  }
815
1233
  } else {
816
1234
  // EXCEPT
817
- const left = executePlan({ plan: plan.left, context })
818
- const right = executePlan({ plan: plan.right, context })
819
1235
  return {
820
1236
  columns: left.columns,
821
1237
  maxRows: left.maxRows,