squirreling 0.12.25 → 0.12.27

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.12.25",
3
+ "version": "0.12.27",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -39,7 +39,7 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "26.0.1",
42
+ "@types/node": "26.1.0",
43
43
  "@vitest/coverage-v8": "4.1.9",
44
44
  "eslint": "9.39.4",
45
45
  "eslint-plugin-jsdoc": "63.0.10",
@@ -0,0 +1,89 @@
1
+ import { keyify } from './utils.js'
2
+
3
+ /**
4
+ * @import { SqlPrimitive } from '../types.js'
5
+ */
6
+
7
+ /**
8
+ * Incremental state for one streamable aggregate. Shared by the streaming
9
+ * aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
10
+ * MIN/MAX fold semantics live in one place.
11
+ *
12
+ * @typedef {{
13
+ * count: number,
14
+ * sum: number,
15
+ * min: SqlPrimitive,
16
+ * max: SqlPrimitive,
17
+ * seen: Set<unknown> | null,
18
+ * }} Accumulator
19
+ */
20
+
21
+ /**
22
+ * @param {string} funcName
23
+ * @param {boolean} [distinct]
24
+ * @returns {Accumulator}
25
+ */
26
+ export function newAccumulator(funcName, distinct) {
27
+ return {
28
+ count: 0,
29
+ sum: 0,
30
+ min: null,
31
+ max: null,
32
+ seen: funcName === 'COUNT' && distinct ? new Set() : null,
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Folds one value into an accumulator, matching the buffered semantics in
38
+ * evaluate.js: COUNT counts non-null, COUNTIF counts truthy, MIN/MAX compare
39
+ * raw values, SUM/AVG only accumulate finite numbers.
40
+ *
41
+ * @param {string} funcName
42
+ * @param {Accumulator} acc
43
+ * @param {SqlPrimitive} value
44
+ */
45
+ export function updateAccumulator(funcName, acc, value) {
46
+ switch (funcName) {
47
+ case 'COUNT':
48
+ if (value == null) break
49
+ if (acc.seen) acc.seen.add(keyify(value))
50
+ else acc.count++
51
+ break
52
+ case 'COUNTIF':
53
+ if (value) acc.count++
54
+ break
55
+ case 'MIN':
56
+ if (value != null && (acc.min === null || value < acc.min)) acc.min = value
57
+ break
58
+ case 'MAX':
59
+ if (value != null && (acc.max === null || value > acc.max)) acc.max = value
60
+ break
61
+ default: { // SUM, AVG
62
+ if (value == null) break
63
+ const num = Number(value)
64
+ if (Number.isFinite(num)) {
65
+ acc.sum += num
66
+ acc.count++
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Reduces an accumulator to its final aggregate value.
74
+ *
75
+ * @param {string} funcName
76
+ * @param {Accumulator} acc
77
+ * @returns {SqlPrimitive}
78
+ */
79
+ export function finalizeAccumulator(funcName, acc) {
80
+ switch (funcName) {
81
+ case 'COUNT': return acc.seen ? acc.seen.size : acc.count
82
+ case 'COUNTIF': return acc.count
83
+ case 'SUM': return acc.count === 0 ? null : acc.sum
84
+ case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
85
+ case 'MIN': return acc.min
86
+ case 'MAX': return acc.max
87
+ default: return null
88
+ }
89
+ }
@@ -1,7 +1,9 @@
1
1
  import { derivedAlias } from '../expression/alias.js'
2
2
  import { evaluateExpr } from '../expression/evaluate.js'
3
+ import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
3
4
  import { executePlan, selectColumnNames } from './execute.js'
4
5
  import { sortEntriesByTerms } from './sort.js'
6
+ import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
5
7
  import { keyify } from './utils.js'
6
8
  import { yieldToEventLoop } from './yield.js'
7
9
 
@@ -80,6 +82,14 @@ function aggregateContextRow(group, aggregateRow) {
80
82
  */
81
83
  export function executeHashAggregate(plan, context) {
82
84
  const child = executePlan({ plan: plan.child, context })
85
+ const streaming = planStreamingAggregates(plan, child.columns)
86
+ if (streaming) {
87
+ return {
88
+ columns: selectColumnNames(plan.columns, child.columns),
89
+ maxRows: child.maxRows,
90
+ rows: streamingHashAggregateRows({ plan, streaming, child, context }),
91
+ }
92
+ }
83
93
  return {
84
94
  columns: selectColumnNames(plan.columns, child.columns),
85
95
  maxRows: child.maxRows,
@@ -196,6 +206,15 @@ export function executeScalarAggregate(plan, context) {
196
206
  }
197
207
 
198
208
  const child = executePlan({ plan: plan.child, context })
209
+ const streaming = planStreamingAggregates(plan, child.columns)
210
+ if (streaming) {
211
+ return {
212
+ columns: selectColumnNames(plan.columns, child.columns),
213
+ numRows: plan.having ? undefined : 1,
214
+ maxRows: 1,
215
+ rows: streamingScalarAggregateRows({ plan, streaming, child, context }),
216
+ }
217
+ }
199
218
  return {
200
219
  columns: selectColumnNames(plan.columns, child.columns),
201
220
  numRows: plan.having ? undefined : 1,
@@ -344,17 +363,6 @@ function extractColumnAggSpec({ expr, alias }) {
344
363
  }
345
364
  }
346
365
 
347
- /**
348
- * @typedef {{
349
- * spec: ColumnAggSpec,
350
- * count: number,
351
- * sum: number,
352
- * min: SqlPrimitive,
353
- * max: SqlPrimitive,
354
- * seen: Set<unknown> | null,
355
- * }} AggAccumulator
356
- */
357
-
358
366
  /**
359
367
  * Scans a column once and computes every aggregate over it in a single pass.
360
368
  * All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
@@ -371,41 +379,15 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
371
379
  const { column } = specs[0]
372
380
  const values = table.scanColumn({ column, limit, offset, signal })
373
381
 
374
- /** @type {AggAccumulator[]} */
375
- const accs = specs.map(spec => /** @type {AggAccumulator} */ ({
376
- spec,
377
- count: 0,
378
- sum: 0,
379
- min: null,
380
- max: null,
381
- seen: spec.funcName === 'COUNT' && spec.distinct ? new Set() : null,
382
- }))
382
+ const accs = specs.map(spec => ({ spec, acc: newAccumulator(spec.funcName, spec.distinct) }))
383
383
 
384
384
  for await (const chunk of values) {
385
385
  signal?.throwIfAborted()
386
386
  for (let i = 0; i < chunk.length; i++) {
387
387
  const v = chunk[i]
388
388
  if (v == null) continue
389
- for (const acc of accs) {
390
- switch (acc.spec.funcName) {
391
- case 'COUNT':
392
- if (acc.seen) acc.seen.add(keyify(v))
393
- else acc.count++
394
- break
395
- case 'MIN':
396
- if (acc.min === null || v < acc.min) acc.min = v
397
- break
398
- case 'MAX':
399
- if (acc.max === null || v > acc.max) acc.max = v
400
- break
401
- default: { // SUM, AVG
402
- const num = Number(v)
403
- if (Number.isFinite(num)) {
404
- acc.sum += num
405
- acc.count++
406
- }
407
- }
408
- }
389
+ for (const { spec, acc } of accs) {
390
+ updateAccumulator(spec.funcName, acc, v)
409
391
  }
410
392
  }
411
393
  }
@@ -413,25 +395,8 @@ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
413
395
 
414
396
  /** @type {Map<string, SqlPrimitive>} */
415
397
  const result = new Map()
416
- for (const acc of accs) {
417
- result.set(acc.spec.alias, finalizeAgg(acc))
398
+ for (const { spec, acc } of accs) {
399
+ result.set(spec.alias, finalizeAccumulator(spec.funcName, acc))
418
400
  }
419
401
  return result
420
402
  }
421
-
422
- /**
423
- * Reduces one accumulator to its final aggregate value.
424
- *
425
- * @param {AggAccumulator} acc
426
- * @returns {SqlPrimitive}
427
- */
428
- function finalizeAgg(acc) {
429
- switch (acc.spec.funcName) {
430
- case 'COUNT': return acc.seen ? acc.seen.size : acc.count
431
- case 'SUM': return acc.count === 0 ? null : acc.sum
432
- case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
433
- case 'MIN': return acc.min
434
- case 'MAX': return acc.max
435
- default: return null
436
- }
437
- }
@@ -24,43 +24,65 @@ export function executeNestedLoopJoin(plan, context) {
24
24
  }
25
25
  const left = executePlan({ plan: plan.left, context })
26
26
  const right = executePlan({ plan: plan.right, context })
27
+ // Buffer the smaller side when both sizes are known, streaming the larger
28
+ // side through the outer loop. Swapping reorders output rows, which SQL
29
+ // leaves unspecified.
30
+ const leftSize = left.numRows ?? left.maxRows
31
+ const rightSize = right.numRows ?? right.maxRows
32
+ const swap = leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
27
33
  return {
28
34
  columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
29
35
  async *rows() {
30
36
  const leftTable = plan.leftAlias
31
37
  const rightTable = plan.rightAlias
38
+ const inner = swap ? left : right
39
+ const outer = swap ? right : left
40
+ // Which sides must also emit their unmatched rows
41
+ const innerOuter = plan.joinType === 'FULL' || plan.joinType === (swap ? 'LEFT' : 'RIGHT')
42
+ const outerOuter = plan.joinType === 'FULL' || plan.joinType === (swap ? 'RIGHT' : 'LEFT')
43
+
44
+ /**
45
+ * @param {AsyncRow} outerRow
46
+ * @param {AsyncRow} innerRow
47
+ * @returns {AsyncRow}
48
+ */
49
+ function merge(outerRow, innerRow) {
50
+ return swap
51
+ ? mergeRows(innerRow, outerRow, leftTable, rightTable)
52
+ : mergeRows(outerRow, innerRow, leftTable, rightTable)
53
+ }
32
54
 
33
- // Buffer right rows
55
+ // Buffer the inner side
34
56
  /** @type {AsyncRow[]} */
35
- const rightRows = []
36
- for await (const row of right.rows()) {
57
+ const innerRows = []
58
+ for await (const row of inner.rows()) {
37
59
  if (context.signal?.aborted) return
38
- rightRows.push(row)
60
+ innerRows.push(row)
39
61
  }
40
62
 
41
- const rightCols = rightRows.length ? rightRows[0].columns : []
63
+ const innerCols = innerRows.length ? innerRows[0].columns : []
42
64
 
43
65
  /** @type {string[] | undefined} */
44
- let leftCols = undefined
66
+ let outerCols = undefined
45
67
  /** @type {Set<AsyncRow> | undefined} */
46
- const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
68
+ const matchedInnerRows = innerOuter ? new Set() : undefined
47
69
 
48
70
  let innerCount = 0
49
- for await (const leftRow of left.rows()) {
71
+ for await (const outerRow of outer.rows()) {
50
72
  if (context.signal?.aborted) return
51
73
 
52
- if (!leftCols) {
53
- leftCols = leftRow.columns
74
+ if (!outerCols) {
75
+ outerCols = outerRow.columns
54
76
  }
55
77
 
56
78
  let hasMatch = false
57
79
 
58
- for (const rightRow of rightRows) {
80
+ for (const innerRow of innerRows) {
59
81
  if (++innerCount % YIELD_INTERVAL === 0) {
60
82
  await yieldToEventLoop()
61
83
  if (context.signal?.aborted) return
62
84
  }
63
- const tempMerged = mergeRows(leftRow, rightRow, leftTable, rightTable)
85
+ const tempMerged = merge(outerRow, innerRow)
64
86
  const matches = await evaluateExpr({
65
87
  node: plan.condition,
66
88
  row: tempMerged,
@@ -69,25 +91,23 @@ export function executeNestedLoopJoin(plan, context) {
69
91
 
70
92
  if (matches) {
71
93
  hasMatch = true
72
- matchedRightRows?.add(rightRow)
94
+ matchedInnerRows?.add(innerRow)
73
95
  yield tempMerged
74
96
  }
75
97
  }
76
98
 
77
- if (!hasMatch && (plan.joinType === 'LEFT' || plan.joinType === 'FULL')) {
78
- const nullRight = createNullRow(rightCols)
79
- yield mergeRows(leftRow, nullRight, leftTable, rightTable)
99
+ if (!hasMatch && outerOuter) {
100
+ yield merge(outerRow, createNullRow(innerCols))
80
101
  }
81
102
  }
82
103
 
83
104
  if (context.signal?.aborted) return
84
105
 
85
- // Unmatched right rows for RIGHT/FULL joins
86
- if (matchedRightRows) {
87
- for (const rightRow of rightRows) {
88
- if (!matchedRightRows.has(rightRow)) {
89
- const nullLeft = createNullRow(leftCols ?? [])
90
- yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
106
+ // Unmatched inner rows for outer joins on the buffered side
107
+ if (matchedInnerRows) {
108
+ for (const innerRow of innerRows) {
109
+ if (!matchedInnerRows.has(innerRow)) {
110
+ yield merge(createNullRow(outerCols ?? []), innerRow)
91
111
  }
92
112
  }
93
113
  }
@@ -167,29 +187,27 @@ export function executePositionalJoin(plan, context) {
167
187
  const leftTable = plan.leftAlias
168
188
  const rightTable = plan.rightAlias
169
189
 
170
- // Buffer both sides (required for positional join)
171
- /** @type {AsyncRow[]} */
172
- const leftRows = []
173
- for await (const row of left.rows()) {
174
- if (signal?.aborted) return
175
- leftRows.push(row)
176
- }
177
-
178
- /** @type {AsyncRow[]} */
179
- const rightRows = []
180
- for await (const row of right.rows()) {
190
+ // Zip both sides in lockstep without buffering either; the shorter
191
+ // side is padded with NULL rows until the longer side is exhausted
192
+ const leftRows = left.rows()
193
+ const rightRows = right.rows()
194
+ /** @type {string[]} */
195
+ let leftCols = []
196
+ /** @type {string[]} */
197
+ let rightCols = []
198
+ let tick = 0
199
+ while (true) {
200
+ const [leftResult, rightResult] = await Promise.all([leftRows.next(), rightRows.next()])
201
+ if (leftResult.done && rightResult.done) return
181
202
  if (signal?.aborted) return
182
- rightRows.push(row)
183
- }
184
-
185
- const maxLen = Math.max(leftRows.length, rightRows.length)
186
- const leftCols = leftRows[0]?.columns ?? []
187
- const rightCols = rightRows[0]?.columns ?? []
188
-
189
- for (let i = 0; i < maxLen; i++) {
190
- if (signal?.aborted) return
191
- const leftRow = leftRows[i] ?? createNullRow(leftCols)
192
- const rightRow = rightRows[i] ?? createNullRow(rightCols)
203
+ if (++tick % YIELD_INTERVAL === 0) {
204
+ await yieldToEventLoop()
205
+ if (signal?.aborted) return
206
+ }
207
+ if (!leftResult.done && !leftCols.length) leftCols = leftResult.value.columns
208
+ if (!rightResult.done && !rightCols.length) rightCols = rightResult.value.columns
209
+ const leftRow = leftResult.done ? createNullRow(leftCols) : leftResult.value
210
+ const rightRow = rightResult.done ? createNullRow(rightCols) : rightResult.value
193
211
  yield mergeRows(leftRow, rightRow, leftTable, rightTable)
194
212
  }
195
213
  },
@@ -206,26 +224,59 @@ export function executePositionalJoin(plan, context) {
206
224
  export function executeHashJoin(plan, context) {
207
225
  const left = executePlan({ plan: plan.left, context })
208
226
  const right = executePlan({ plan: plan.right, context })
227
+ // Build the hash table on the smaller side when both sizes are known,
228
+ // so joining a small table against a large one buffers the small one.
229
+ // Swapping reorders output rows, which SQL leaves unspecified.
230
+ const leftSize = left.numRows ?? left.maxRows
231
+ const rightSize = right.numRows ?? right.maxRows
232
+ const swap = leftSize !== undefined && rightSize !== undefined && leftSize < rightSize
209
233
  return {
210
234
  columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
211
235
  async *rows() {
212
236
  const leftTable = plan.leftAlias
213
237
  const rightTable = plan.rightAlias
214
- const { leftKeys, rightKeys, residual } = plan
215
-
216
- // Buffer right rows and build hash map
217
- /** @type {AsyncRow[]} */
218
- const rightRows = []
219
- for await (const row of right.rows()) {
220
- if (context.signal?.aborted) return
221
- rightRows.push(row)
238
+ const { residual } = plan
239
+ const build = swap ? left : right
240
+ const probe = swap ? right : left
241
+ const buildKeys = swap ? plan.leftKeys : plan.rightKeys
242
+ const probeKeys = swap ? plan.rightKeys : plan.leftKeys
243
+ // Which sides must also emit their unmatched rows
244
+ const buildOuter = plan.joinType === 'FULL' || plan.joinType === (swap ? 'LEFT' : 'RIGHT')
245
+ const probeOuter = plan.joinType === 'FULL' || plan.joinType === (swap ? 'RIGHT' : 'LEFT')
246
+
247
+ /**
248
+ * @param {AsyncRow} probeRow
249
+ * @param {AsyncRow} buildRow
250
+ * @returns {AsyncRow}
251
+ */
252
+ function merge(probeRow, buildRow) {
253
+ return swap
254
+ ? mergeRows(buildRow, probeRow, leftTable, rightTable)
255
+ : mergeRows(probeRow, buildRow, leftTable, rightTable)
222
256
  }
223
257
 
258
+ // Build phase: stream one side into the hash map. The full row list is
259
+ // only retained when unmatched build rows must be emitted afterwards;
260
+ // otherwise rows with NULL join keys are released immediately.
224
261
  /** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
225
262
  const hashMap = new Map()
226
- for (const rightRow of rightRows) {
263
+ /** @type {AsyncRow[] | undefined} */
264
+ const buildRows = buildOuter ? [] : undefined
265
+ /** @type {string[]} */
266
+ let buildCols = []
267
+ let innerCount = 0
268
+ for await (const buildRow of build.rows()) {
269
+ if (context.signal?.aborted) return
270
+ if (++innerCount % YIELD_INTERVAL === 0) {
271
+ await yieldToEventLoop()
272
+ if (context.signal?.aborted) return
273
+ }
274
+ if (!buildCols.length) {
275
+ buildCols = buildRow.columns
276
+ }
277
+ buildRows?.push(buildRow)
227
278
  const keyValues = await Promise.all(
228
- rightKeys.map(node => evaluateExpr({ node, row: rightRow, context }))
279
+ buildKeys.map(node => evaluateExpr({ node, row: buildRow, context }))
229
280
  )
230
281
  // SQL semantics: NULL never equals anything, so a row with any NULL
231
282
  // join key is excluded from the hash table.
@@ -236,65 +287,59 @@ export function executeHashJoin(plan, context) {
236
287
  bucket = []
237
288
  hashMap.set(key, bucket)
238
289
  }
239
- bucket.push(rightRow)
290
+ bucket.push(buildRow)
240
291
  }
241
292
 
242
- // Get column info for NULL row generation
243
- const rightCols = rightRows.length ? rightRows[0].columns : []
244
-
245
293
  /** @type {string[] | undefined} */
246
- let leftCols
294
+ let probeCols
247
295
  /** @type {Set<AsyncRow> | undefined} */
248
- const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
296
+ const matchedBuildRows = buildOuter ? new Set() : undefined
249
297
 
250
- // Probe phase: stream left rows
251
- let innerCount = 0
252
- for await (const leftRow of left.rows()) {
298
+ // Probe phase: stream the other side
299
+ for await (const probeRow of probe.rows()) {
253
300
  if (context.signal?.aborted) return
254
301
 
255
- if (!leftCols) {
256
- leftCols = leftRow.columns
302
+ if (!probeCols) {
303
+ probeCols = probeRow.columns
257
304
  }
258
305
 
259
306
  const keyValues = await Promise.all(
260
- leftKeys.map(node => evaluateExpr({ node, row: leftRow, context }))
307
+ probeKeys.map(node => evaluateExpr({ node, row: probeRow, context }))
261
308
  )
262
309
  let matched = false
263
310
  if (!keyValues.some(v => v == null)) {
264
311
  const key = keyify(...keyValues)
265
312
  const candidates = hashMap.get(key)
266
313
  if (candidates?.length) {
267
- for (const rightRow of candidates) {
314
+ for (const buildRow of candidates) {
268
315
  if (++innerCount % YIELD_INTERVAL === 0) {
269
316
  await yieldToEventLoop()
270
317
  if (context.signal?.aborted) return
271
318
  }
272
- const merged = mergeRows(leftRow, rightRow, leftTable, rightTable)
319
+ const merged = merge(probeRow, buildRow)
273
320
  if (residual) {
274
321
  const ok = await evaluateExpr({ node: residual, row: merged, context })
275
322
  if (!ok) continue
276
323
  }
277
324
  matched = true
278
- matchedRightRows?.add(rightRow)
325
+ matchedBuildRows?.add(buildRow)
279
326
  yield merged
280
327
  }
281
328
  }
282
329
  }
283
330
 
284
- if (!matched && (plan.joinType === 'LEFT' || plan.joinType === 'FULL')) {
285
- const nullRight = createNullRow(rightCols)
286
- yield mergeRows(leftRow, nullRight, leftTable, rightTable)
331
+ if (!matched && probeOuter) {
332
+ yield merge(probeRow, createNullRow(buildCols))
287
333
  }
288
334
  }
289
335
 
290
336
  if (context.signal?.aborted) return
291
337
 
292
- // Unmatched right rows for RIGHT/FULL joins
293
- if (matchedRightRows) {
294
- for (const rightRow of rightRows) {
295
- if (!matchedRightRows.has(rightRow)) {
296
- const nullLeft = createNullRow(leftCols ?? [])
297
- yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
338
+ // Unmatched build rows for outer joins on the build side
339
+ if (buildRows && matchedBuildRows) {
340
+ for (const buildRow of buildRows) {
341
+ if (!matchedBuildRows.has(buildRow)) {
342
+ yield merge(createNullRow(probeCols ?? []), buildRow)
298
343
  }
299
344
  }
300
345
  }
@@ -4,16 +4,21 @@ import { executePlan } from './execute.js'
4
4
  import { compareForTerm } from './utils.js'
5
5
 
6
6
  /**
7
- * @import { AsyncRow, ExecuteContext, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
7
+ * @import { AsyncRow, ExecuteContext, ExprNode, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
8
8
  * @import { SortNode } from '../plan/types.js'
9
9
  */
10
10
 
11
11
  const MAX_CHUNK = 256
12
12
 
13
13
  /**
14
+ * When exprs is set, exprs[i] is evaluated for ORDER BY term i instead of the
15
+ * term's own expression, so callers can pre-substitute per-entry values (for
16
+ * example, finalized aggregates) while keeping tie-aware term evaluation.
17
+ *
14
18
  * @typedef {{
15
19
  * row: AsyncRow,
16
20
  * rows?: AsyncRow[],
21
+ * exprs?: ExprNode[],
17
22
  * }} SortEntry
18
23
  */
19
24
 
@@ -63,7 +68,7 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
63
68
  const chunk = missing.slice(start, start + chunkSize)
64
69
  const values = await Promise.all(chunk.map(idx =>
65
70
  evaluateExpr({
66
- node: term.expr,
71
+ node: entries[idx].exprs?.[orderByIdx] ?? term.expr,
67
72
  row: entries[idx].row,
68
73
  rows: entries[idx].rows,
69
74
  context,
@@ -124,25 +129,47 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
124
129
  */
125
130
  export function executeSort(plan, context) {
126
131
  const child = executePlan({ plan: plan.child, context })
132
+ const { topK } = plan
133
+ // With a LIMIT bound pushed into the sort, keep at most this many buffered
134
+ // rows: periodically sort and discard everything past topK, so memory is
135
+ // bounded by the limit instead of the input size.
136
+ const bufferLimit = topK === undefined ? Infinity : Math.max(topK * 2, 1024)
127
137
  return {
128
138
  columns: child.columns,
129
- numRows: child.numRows,
130
- maxRows: child.maxRows,
139
+ numRows: topK === undefined || child.numRows === undefined
140
+ ? child.numRows
141
+ : Math.min(child.numRows, topK),
142
+ maxRows: topK === undefined
143
+ ? child.maxRows
144
+ : Math.min(child.maxRows ?? Infinity, topK),
131
145
  async *rows() {
132
- // Buffer all rows
133
- /** @type {AsyncRow[]} */
134
- const rows = []
146
+ /** @type {SortEntry[]} */
147
+ let entries = []
135
148
  for await (const row of child.rows()) {
136
149
  if (context.signal?.aborted) return
137
- rows.push(row)
150
+ entries.push({ row })
151
+ if (entries.length >= bufferLimit) {
152
+ // Sort keys are cached on the rows, so survivors of one truncation
153
+ // are not re-evaluated by the next
154
+ const sorted = await sortEntriesByTerms({
155
+ entries,
156
+ orderBy: plan.orderBy,
157
+ context,
158
+ cacheValues: true,
159
+ })
160
+ entries = sorted.slice(0, topK).map(({ row }) => ({ row }))
161
+ }
138
162
  }
139
163
 
140
- const sortedRows = await sortEntriesByTerms({
141
- entries: rows.map(row => ({ row })),
164
+ let sortedRows = await sortEntriesByTerms({
165
+ entries,
142
166
  orderBy: plan.orderBy,
143
167
  context,
144
168
  cacheValues: true,
145
169
  })
170
+ if (topK !== undefined && sortedRows.length > topK) {
171
+ sortedRows = sortedRows.slice(0, topK)
172
+ }
146
173
 
147
174
  // Yield sorted rows
148
175
  for (const { row } of sortedRows) {