squirreling 0.11.4 → 0.12.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.
@@ -7,10 +7,10 @@ import { validateScan, validateTable } from '../validation/tables.js'
7
7
  import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
8
8
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
9
9
  import { executeSort } from './sort.js'
10
- import { stableRowKey } from './utils.js'
10
+ import { addBounds, minBounds, stableRowKey } from './utils.js'
11
11
 
12
12
  /**
13
- * @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, Statement } from '../types.js'
13
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, QueryResults, Statement } from '../types.js'
14
14
  * @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode } from '../plan/types.js'
15
15
  */
16
16
 
@@ -18,9 +18,9 @@ import { stableRowKey } from './utils.js'
18
18
  * Executes a SQL SELECT query against tables
19
19
  *
20
20
  * @param {ExecuteSqlOptions} options
21
- * @yields {AsyncRow}
21
+ * @returns {QueryResults}
22
22
  */
23
- export async function* executeSql({ tables, query, functions, signal }) {
23
+ export function executeSql({ tables, query, functions, signal }) {
24
24
  const parsed = typeof query === 'string' ? parseSql({ query, functions }) : query
25
25
 
26
26
  // Normalize tables: convert arrays to AsyncDataSource
@@ -34,7 +34,9 @@ export async function* executeSql({ tables, query, functions, signal }) {
34
34
  }
35
35
  }
36
36
 
37
- yield* executeStatement({ query: parsed, context: { tables: normalizedTables, functions, signal } })
37
+ const context = { tables: normalizedTables, functions, signal }
38
+ const plan = planSql({ query: parsed, functions, tables: normalizedTables })
39
+ return executePlan({ plan, context })
38
40
  }
39
41
 
40
42
  /**
@@ -43,60 +45,62 @@ export async function* executeSql({ tables, query, functions, signal }) {
43
45
  * @param {Object} options
44
46
  * @param {Statement} options.query
45
47
  * @param {ExecuteContext} options.context
46
- * @yields {AsyncRow}
48
+ * @returns {QueryResults}
47
49
  */
48
- export async function* executeStatement({ query, context }) {
50
+ export function executeStatement({ query, context }) {
49
51
  const plan = planSql({ query, functions: context.functions, tables: context.tables })
50
- yield* executePlan({ plan, context })
52
+ return executePlan({ plan, context })
51
53
  }
52
54
 
53
55
  /**
54
- * Executes a query plan and yields result rows
56
+ * Executes a query plan and returns query results with row count estimates
55
57
  *
56
58
  * @param {Object} options
57
59
  * @param {QueryPlan} options.plan - the query plan to execute
58
60
  * @param {ExecuteContext} options.context - execution context
59
- * @returns {AsyncGenerator<AsyncRow>}
61
+ * @returns {QueryResults}
60
62
  */
61
- export async function* executePlan({ plan, context }) {
63
+ export function executePlan({ plan, context }) {
62
64
  if (plan.type === 'Scan') {
63
- yield* executeScan(plan, context)
65
+ return executeScan(plan, context)
64
66
  } else if (plan.type === 'Count') {
65
- yield* executeCount(plan, context)
67
+ return executeCount(plan, context)
66
68
  } else if (plan.type === 'Filter') {
67
- yield* executeFilter(plan, context)
69
+ return executeFilter(plan, context)
68
70
  } else if (plan.type === 'Project') {
69
- yield* executeProject(plan, context)
71
+ return executeProject(plan, context)
70
72
  } else if (plan.type === 'HashJoin') {
71
- yield* executeHashJoin(plan, context)
73
+ return executeHashJoin(plan, context)
72
74
  } else if (plan.type === 'NestedLoopJoin') {
73
- yield* executeNestedLoopJoin(plan, context)
75
+ return executeNestedLoopJoin(plan, context)
74
76
  } else if (plan.type === 'PositionalJoin') {
75
- yield* executePositionalJoin(plan, context)
77
+ return executePositionalJoin(plan, context)
76
78
  } else if (plan.type === 'HashAggregate') {
77
- yield* executeHashAggregate(plan, context)
79
+ return executeHashAggregate(plan, context)
78
80
  } else if (plan.type === 'ScalarAggregate') {
79
- yield* executeScalarAggregate(plan, context)
81
+ return executeScalarAggregate(plan, context)
80
82
  } else if (plan.type === 'Sort') {
81
- yield* executeSort(plan, context)
83
+ return executeSort(plan, context)
82
84
  } else if (plan.type === 'Distinct') {
83
- yield* executeDistinct(plan, context)
85
+ return executeDistinct(plan, context)
84
86
  } else if (plan.type === 'Limit') {
85
- yield* executeLimit(plan, context)
87
+ return executeLimit(plan, context)
86
88
  } else if (plan.type === 'SetOperation') {
87
- yield* executeSetOperation(plan, context)
89
+ return executeSetOperation(plan, context)
88
90
  }
91
+ return { async *rows () {} }
89
92
  }
90
93
 
91
94
  /**
92
95
  * @param {ScanNode} plan
93
96
  * @param {ExecuteContext} context
94
- * @yields {AsyncRow}
97
+ * @returns {QueryResults}
95
98
  */
96
- async function* executeScan(plan, context) {
99
+ function executeScan(plan, context) {
97
100
  const { tables, signal } = context
98
101
  const table = validateTable({ ...plan, tables })
99
102
  validateScan({ ...plan, tables })
103
+ const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
100
104
 
101
105
  // Fast path: single column scan without WHERE
102
106
  if (table.scanColumn && plan.hints.columns?.length === 1 && !plan.hints.where) {
@@ -107,42 +111,55 @@ async function* executeScan(plan, context) {
107
111
  offset: plan.hints.offset,
108
112
  signal,
109
113
  })
110
- const columns = [column]
111
- for await (const chunk of chunks) {
112
- if (signal?.aborted) return
113
- for (let i = 0; i < chunk.length; i++) {
114
- const value = chunk[i]
115
- yield {
116
- columns,
117
- cells: { [column]: () => Promise.resolve(value) },
114
+ const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
115
+ return {
116
+ numRows: scanRows,
117
+ maxRows: scanRows,
118
+ async *rows () {
119
+ const columns = [column]
120
+ for await (const chunk of chunks) {
121
+ if (signal?.aborted) return
122
+ for (let i = 0; i < chunk.length; i++) {
123
+ const value = chunk[i]
124
+ yield {
125
+ columns,
126
+ cells: { [column]: () => Promise.resolve(value) },
127
+ }
128
+ }
118
129
  }
119
- }
130
+ },
120
131
  }
121
- return
122
132
  }
123
133
 
124
134
  // do the scan
125
- const { rows, appliedWhere, appliedLimitOffset } = table.scan({ ...plan.hints, signal })
135
+ const scanResult = table.scan({ ...plan.hints, signal })
136
+ const { appliedWhere, appliedLimitOffset } = scanResult
126
137
 
127
138
  // Applied limit/offset without applied where is invalid
128
- const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
129
139
  if (!appliedWhere && appliedLimitOffset && plan.hints.where && hasLimitOffset) {
130
140
  throw new Error(`Data source "${plan.table}" applied limit/offset without applying where`)
131
141
  }
132
142
 
133
- let result = rows
143
+ const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
144
+ return {
145
+ numRows: !plan.hints.where ? scanRows : undefined,
146
+ maxRows: scanRows,
147
+ async *rows () {
148
+ let result = scanResult.rows()
134
149
 
135
- // Apply WHERE if data source did not
136
- if (!appliedWhere && plan.hints.where) {
137
- result = filterRows(result, plan.hints.where, context, plan.hints.limit)
138
- }
150
+ // Apply WHERE if data source did not
151
+ if (!appliedWhere && plan.hints.where) {
152
+ result = filterRows(result, plan.hints.where, context, plan.hints.limit)
153
+ }
139
154
 
140
- // Apply LIMIT/OFFSET if data source did not
141
- if (!appliedLimitOffset && hasLimitOffset) {
142
- result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
143
- }
155
+ // Apply LIMIT/OFFSET if data source did not
156
+ if (!appliedLimitOffset && hasLimitOffset) {
157
+ result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
158
+ }
144
159
 
145
- yield* result
160
+ yield* result
161
+ },
162
+ }
146
163
  }
147
164
 
148
165
  /**
@@ -150,34 +167,55 @@ async function* executeScan(plan, context) {
150
167
  *
151
168
  * @param {CountNode} plan
152
169
  * @param {ExecuteContext} context
153
- * @yields {AsyncRow}
170
+ * @returns {QueryResults}
154
171
  */
155
- async function* executeCount(plan, { tables, signal }) {
172
+ function executeCount(plan, context) {
173
+ const { tables, signal } = context
156
174
  const table = validateTable({ ...plan, tables })
157
175
 
158
- // Use source numRows if available
159
- let count = table.numRows
160
- if (count === undefined) {
161
- // Fall back to counting rows via scan
162
- count = 0
163
- const { rows } = table.scan({ signal })
164
- // eslint-disable-next-line no-unused-vars
165
- for await (const _ of rows) {
166
- if (signal?.aborted) return
167
- count++
168
- }
169
- }
176
+ return {
177
+ numRows: 1,
178
+ maxRows: 1,
179
+ async *rows () {
180
+ // Use source numRows if available
181
+ let count = table.numRows
182
+ if (count === undefined) {
183
+ // Fall back to counting rows via scan
184
+ count = 0
185
+ const { rows } = table.scan({ signal })
186
+ // eslint-disable-next-line no-unused-vars
187
+ for await (const _ of rows()) {
188
+ if (signal?.aborted) return
189
+ count++
190
+ }
191
+ }
170
192
 
171
- /** @type {string[]} */
172
- const columns = []
173
- /** @type {AsyncCells} */
174
- const cells = {}
175
- for (const col of plan.columns) {
176
- const alias = col.alias ?? derivedAlias(col.expr)
177
- columns.push(alias)
178
- cells[alias] = () => Promise.resolve(count)
193
+ /** @type {string[]} */
194
+ const columns = []
195
+ /** @type {AsyncCells} */
196
+ const cells = {}
197
+ for (const col of plan.columns) {
198
+ const alias = col.alias ?? derivedAlias(col.expr)
199
+ columns.push(alias)
200
+ cells[alias] = () => Promise.resolve(count)
201
+ }
202
+ yield { columns, cells }
203
+ },
179
204
  }
180
- yield { columns, cells }
205
+ }
206
+
207
+ /**
208
+ * Computes numRows for a scan when the table provides numRows and there is no WHERE.
209
+ *
210
+ * @param {number | undefined} tableNumRows
211
+ * @param {number} [limit]
212
+ * @param {number} [offset]
213
+ * @returns {number | undefined}
214
+ */
215
+ function computeScanRows(tableNumRows, limit, offset) {
216
+ if (tableNumRows === undefined) return undefined
217
+ const afterOffset = Math.max(0, tableNumRows - (offset ?? 0))
218
+ return limit !== undefined ? Math.min(limit, afterOffset) : afterOffset
181
219
  }
182
220
 
183
221
  /**
@@ -257,10 +295,14 @@ async function* limitRows(rows, limit, offset, signal) {
257
295
  *
258
296
  * @param {FilterNode} plan
259
297
  * @param {ExecuteContext} context
260
- * @yields {AsyncRow}
298
+ * @returns {QueryResults}
261
299
  */
262
- async function* executeFilter(plan, context) {
263
- yield* filterRows(executePlan({ plan: plan.child, context }), plan.condition, context)
300
+ function executeFilter(plan, context) {
301
+ const child = executePlan({ plan: plan.child, context })
302
+ return {
303
+ maxRows: child.maxRows,
304
+ rows: () => filterRows(child.rows(), plan.condition, context),
305
+ }
264
306
  }
265
307
 
266
308
  /**
@@ -268,45 +310,52 @@ async function* executeFilter(plan, context) {
268
310
  *
269
311
  * @param {ProjectNode} plan
270
312
  * @param {ExecuteContext} context
271
- * @yields {AsyncRow}
313
+ * @returns {QueryResults}
272
314
  */
273
- async function* executeProject(plan, context) {
274
- let rowIndex = 0
275
-
276
- for await (const row of executePlan({ plan: plan.child, context })) {
277
- if (context.signal?.aborted) return
278
- rowIndex++
279
- const currentRowIndex = rowIndex
280
-
281
- /** @type {string[]} */
282
- const columns = []
283
- /** @type {AsyncCells} */
284
- const cells = {}
285
-
286
- for (const col of plan.columns) {
287
- if (col.type === 'star') {
288
- const prefix = col.table ? `${col.table}.` : undefined
289
- for (const key of row.columns) {
290
- if (prefix && !key.startsWith(prefix)) continue
291
- // Strip table prefix for output column names
292
- const dotIndex = key.indexOf('.')
293
- const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
294
- columns.push(outputKey)
295
- cells[outputKey] = row.cells[key]
315
+ function executeProject(plan, context) {
316
+ const child = executePlan({ plan: plan.child, context })
317
+ return {
318
+ numRows: child.numRows,
319
+ maxRows: child.maxRows,
320
+ async *rows () {
321
+ let rowIndex = 0
322
+
323
+ for await (const row of child.rows()) {
324
+ if (context.signal?.aborted) return
325
+ rowIndex++
326
+ const currentRowIndex = rowIndex
327
+
328
+ /** @type {string[]} */
329
+ const columns = []
330
+ /** @type {AsyncCells} */
331
+ const cells = {}
332
+
333
+ for (const col of plan.columns) {
334
+ if (col.type === 'star') {
335
+ const prefix = col.table ? `${col.table}.` : undefined
336
+ for (const key of row.columns) {
337
+ if (prefix && !key.startsWith(prefix)) continue
338
+ // Strip table prefix for output column names
339
+ const dotIndex = key.indexOf('.')
340
+ const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
341
+ columns.push(outputKey)
342
+ cells[outputKey] = row.cells[key]
343
+ }
344
+ } else {
345
+ const alias = col.alias ?? derivedAlias(col.expr)
346
+ columns.push(alias)
347
+ cells[alias] = () => evaluateExpr({
348
+ node: col.expr,
349
+ row,
350
+ rowIndex: currentRowIndex,
351
+ context,
352
+ })
353
+ }
296
354
  }
297
- } else {
298
- const alias = col.alias ?? derivedAlias(col.expr)
299
- columns.push(alias)
300
- cells[alias] = () => evaluateExpr({
301
- node: col.expr,
302
- row,
303
- rowIndex: currentRowIndex,
304
- context,
305
- })
306
- }
307
- }
308
355
 
309
- yield { columns, cells }
356
+ yield { columns, cells }
357
+ }
358
+ },
310
359
  }
311
360
  }
312
361
 
@@ -315,44 +364,50 @@ async function* executeProject(plan, context) {
315
364
  *
316
365
  * @param {DistinctNode} plan
317
366
  * @param {ExecuteContext} context
318
- * @yields {AsyncRow}
367
+ * @returns {QueryResults}
319
368
  */
320
- async function* executeDistinct(plan, context) {
321
- const { signal } = context
322
- const MAX_CHUNK = 256
323
-
324
- const seen = new Set()
369
+ function executeDistinct(plan, context) {
370
+ const child = executePlan({ plan: plan.child, context })
371
+ return {
372
+ maxRows: child.maxRows,
373
+ async *rows () {
374
+ const { signal } = context
375
+ const MAX_CHUNK = 256
325
376
 
326
- /** @type {AsyncRow[]} */
327
- let buffer = []
377
+ const seen = new Set()
328
378
 
329
- for await (const row of executePlan({ plan: plan.child, context })) {
330
- if (signal?.aborted) return
331
- buffer.push(row)
379
+ /** @type {AsyncRow[]} */
380
+ let buffer = []
332
381
 
333
- if (buffer.length >= MAX_CHUNK) {
334
- const keys = buffer.map(stableRowKey)
335
- for (let i = 0; i < buffer.length; i++) {
336
- const key = await keys[i]
337
- if (!seen.has(key)) {
338
- seen.add(key)
339
- yield buffer[i]
382
+ for await (const row of child.rows()) {
383
+ if (signal?.aborted) return
384
+ buffer.push(row)
385
+
386
+ if (buffer.length >= MAX_CHUNK) {
387
+ const keys = buffer.map(stableRowKey)
388
+ for (let i = 0; i < buffer.length; i++) {
389
+ const key = await keys[i]
390
+ if (!seen.has(key)) {
391
+ seen.add(key)
392
+ yield buffer[i]
393
+ }
394
+ }
395
+ buffer = []
340
396
  }
341
397
  }
342
- buffer = []
343
- }
344
- }
345
398
 
346
- // Flush remaining
347
- if (buffer.length > 0) {
348
- const keys = buffer.map(stableRowKey)
349
- for (let i = 0; i < buffer.length; i++) {
350
- const key = await keys[i]
351
- if (!seen.has(key)) {
352
- seen.add(key)
353
- yield buffer[i]
399
+ // Flush remaining
400
+ if (buffer.length > 0) {
401
+ const keys = buffer.map(stableRowKey)
402
+ for (let i = 0; i < buffer.length; i++) {
403
+ const key = await keys[i]
404
+ if (!seen.has(key)) {
405
+ seen.add(key)
406
+ yield buffer[i]
407
+ }
408
+ }
354
409
  }
355
- }
410
+ },
356
411
  }
357
412
  }
358
413
 
@@ -361,10 +416,15 @@ async function* executeDistinct(plan, context) {
361
416
  *
362
417
  * @param {LimitNode} plan
363
418
  * @param {ExecuteContext} context
364
- * @yields {AsyncRow}
419
+ * @returns {QueryResults}
365
420
  */
366
- async function* executeLimit(plan, context) {
367
- yield* limitRows(executePlan({ plan: plan.child, context }), plan.limit, plan.offset, context.signal)
421
+ function executeLimit(plan, context) {
422
+ const child = executePlan({ plan: plan.child, context })
423
+ return {
424
+ numRows: computeScanRows(child.numRows, plan.limit, plan.offset),
425
+ maxRows: computeScanRows(child.maxRows, plan.limit, plan.offset),
426
+ rows: () => limitRows(child.rows(), plan.limit, plan.offset, context.signal),
427
+ }
368
428
  }
369
429
 
370
430
  /**
@@ -372,102 +432,132 @@ async function* executeLimit(plan, context) {
372
432
  *
373
433
  * @param {SetOperationNode} plan
374
434
  * @param {ExecuteContext} context
375
- * @yields {AsyncRow}
435
+ * @returns {QueryResults}
376
436
  */
377
- async function* executeSetOperation(plan, context) {
437
+ function executeSetOperation(plan, context) {
378
438
  const { signal } = context
379
439
 
380
440
  if (plan.operator === 'UNION') {
381
441
  if (plan.all) {
382
- // UNION ALL: yield all rows from both sides
383
- yield* executePlan({ plan: plan.left, context })
384
- yield* executePlan({ plan: plan.right, context })
385
- } else {
386
- // UNION: yield deduplicated rows from both sides
387
- const seen = new Set()
388
- for await (const row of executePlan({ plan: plan.left, context })) {
389
- if (signal?.aborted) return
390
- const key = await stableRowKey(row)
391
- if (!seen.has(key)) {
392
- seen.add(key)
393
- yield row
394
- }
442
+ const left = executePlan({ plan: plan.left, context })
443
+ const right = executePlan({ plan: plan.right, context })
444
+ return {
445
+ numRows: addBounds(left.numRows, right.numRows),
446
+ maxRows: addBounds(left.maxRows, right.maxRows),
447
+ async *rows () {
448
+ // UNION ALL: yield all rows from both sides
449
+ yield* left.rows()
450
+ yield* right.rows()
451
+ },
395
452
  }
396
- for await (const row of executePlan({ plan: plan.right, context })) {
397
- if (signal?.aborted) return
398
- const key = await stableRowKey(row)
399
- if (!seen.has(key)) {
400
- seen.add(key)
401
- yield row
402
- }
453
+ } else {
454
+ const left = executePlan({ plan: plan.left, context })
455
+ const right = executePlan({ plan: plan.right, context })
456
+ return {
457
+ maxRows: addBounds(left.maxRows, right.maxRows),
458
+ async *rows () {
459
+ // UNION: yield deduplicated rows from both sides
460
+ const seen = new Set()
461
+ for await (const row of left.rows()) {
462
+ if (signal?.aborted) return
463
+ const key = await stableRowKey(row)
464
+ if (!seen.has(key)) {
465
+ seen.add(key)
466
+ yield row
467
+ }
468
+ }
469
+ for await (const row of right.rows()) {
470
+ if (signal?.aborted) return
471
+ const key = await stableRowKey(row)
472
+ if (!seen.has(key)) {
473
+ seen.add(key)
474
+ yield row
475
+ }
476
+ }
477
+ },
403
478
  }
404
479
  }
405
480
  } else if (plan.operator === 'INTERSECT') {
406
- // Materialize right side keys
407
- /** @type {Map<any, number>} */
408
- const rightKeys = new Map()
409
- for await (const row of executePlan({ plan: plan.right, context })) {
410
- if (signal?.aborted) return
411
- const key = await stableRowKey(row)
412
- rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
413
- }
414
-
415
- if (plan.all) {
416
- // INTERSECT ALL: yield each left row that matches, consuming right counts
417
- for await (const row of executePlan({ plan: plan.left, context })) {
418
- if (signal?.aborted) return
419
- const key = await stableRowKey(row)
420
- const count = rightKeys.get(key)
421
- if (count) {
422
- rightKeys.set(key, count - 1)
423
- yield row
481
+ const left = executePlan({ plan: plan.left, context })
482
+ const right = executePlan({ plan: plan.right, context })
483
+ return {
484
+ maxRows: minBounds(left.maxRows, right.maxRows),
485
+ async *rows () {
486
+ // Materialize right side keys
487
+ /** @type {Map<any, number>} */
488
+ const rightKeys = new Map()
489
+ for await (const row of right.rows()) {
490
+ if (signal?.aborted) return
491
+ const key = await stableRowKey(row)
492
+ rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
424
493
  }
425
- }
426
- } else {
427
- // INTERSECT: yield deduplicated rows present in both
428
- const seen = new Set()
429
- for await (const row of executePlan({ plan: plan.left, context })) {
430
- if (signal?.aborted) return
431
- const key = await stableRowKey(row)
432
- if (rightKeys.has(key) && !seen.has(key)) {
433
- seen.add(key)
434
- yield row
494
+
495
+ if (plan.all) {
496
+ // INTERSECT ALL: yield each left row that matches, consuming right counts
497
+ for await (const row of left.rows()) {
498
+ if (signal?.aborted) return
499
+ const key = await stableRowKey(row)
500
+ const count = rightKeys.get(key)
501
+ if (count) {
502
+ rightKeys.set(key, count - 1)
503
+ yield row
504
+ }
505
+ }
506
+ } else {
507
+ // INTERSECT: yield deduplicated rows present in both
508
+ const seen = new Set()
509
+ for await (const row of left.rows()) {
510
+ if (signal?.aborted) return
511
+ const key = await stableRowKey(row)
512
+ if (rightKeys.has(key) && !seen.has(key)) {
513
+ seen.add(key)
514
+ yield row
515
+ }
516
+ }
435
517
  }
436
- }
437
- }
438
- } else if (plan.operator === 'EXCEPT') {
439
- // Materialize right side keys
440
- /** @type {Map<any, number>} */
441
- const rightKeys = new Map()
442
- for await (const row of executePlan({ plan: plan.right, context })) {
443
- if (signal?.aborted) return
444
- const key = await stableRowKey(row)
445
- rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
518
+ },
446
519
  }
520
+ } else {
521
+ // EXCEPT
522
+ const left = executePlan({ plan: plan.left, context })
523
+ const right = executePlan({ plan: plan.right, context })
524
+ return {
525
+ maxRows: left.maxRows,
526
+ async *rows () {
527
+ // Materialize right side keys
528
+ /** @type {Map<any, number>} */
529
+ const rightKeys = new Map()
530
+ for await (const row of right.rows()) {
531
+ if (signal?.aborted) return
532
+ const key = await stableRowKey(row)
533
+ rightKeys.set(key, (rightKeys.get(key) ?? 0) + 1)
534
+ }
447
535
 
448
- if (plan.all) {
449
- // EXCEPT ALL: yield left rows, consuming right counts
450
- for await (const row of executePlan({ plan: plan.left, context })) {
451
- if (signal?.aborted) return
452
- const key = await stableRowKey(row)
453
- const count = rightKeys.get(key)
454
- if (count) {
455
- rightKeys.set(key, count - 1)
536
+ if (plan.all) {
537
+ // EXCEPT ALL: yield left rows, consuming right counts
538
+ for await (const row of left.rows()) {
539
+ if (signal?.aborted) return
540
+ const key = await stableRowKey(row)
541
+ const count = rightKeys.get(key)
542
+ if (count) {
543
+ rightKeys.set(key, count - 1)
544
+ } else {
545
+ yield row
546
+ }
547
+ }
456
548
  } else {
457
- yield row
458
- }
459
- }
460
- } else {
461
- // EXCEPT: yield deduplicated left rows not in right
462
- const seen = new Set()
463
- for await (const row of executePlan({ plan: plan.left, context })) {
464
- if (signal?.aborted) return
465
- const key = await stableRowKey(row)
466
- if (!rightKeys.has(key) && !seen.has(key)) {
467
- seen.add(key)
468
- yield row
549
+ // EXCEPT: yield deduplicated left rows not in right
550
+ const seen = new Set()
551
+ for await (const row of left.rows()) {
552
+ if (signal?.aborted) return
553
+ const key = await stableRowKey(row)
554
+ if (!rightKeys.has(key) && !seen.has(key)) {
555
+ seen.add(key)
556
+ yield row
557
+ }
558
+ }
469
559
  }
470
- }
560
+ },
471
561
  }
472
562
  }
473
563
  }