squirreling 0.12.24 → 0.12.26

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/README.md CHANGED
@@ -12,6 +12,8 @@
12
12
 
13
13
  Squirreling is a streaming async SQL engine in pure JavaScript. Built for the browser from the ground up: streaming input and output, pluggable data sources, and lazy async cell evaluation. This makes Squirreling ideal for querying data from network sources, APIs, or LLMs where latency and cost matter.
14
14
 
15
+ > Part of **[HypStack](https://hypstack.ai/)**, an open-source stack for AI observability.
16
+
15
17
  - **Standard SQL**: Full SQL support for querying data (read-only)
16
18
  - **Async UDFs**: User-defined functions can call APIs or models
17
19
  - **Tiny**: 13 kb bundle, zero dependencies, instant startup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.24",
3
+ "version": "0.12.26",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -39,11 +39,11 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "25.9.3",
43
- "@vitest/coverage-v8": "4.1.8",
42
+ "@types/node": "26.1.0",
43
+ "@vitest/coverage-v8": "4.1.9",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.2",
45
+ "eslint-plugin-jsdoc": "63.0.10",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.8"
47
+ "vitest": "4.1.9"
48
48
  }
49
49
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AsyncCells, AsyncDataSource, AsyncRow, ScanColumnOptions, SqlPrimitive } from '../types.js'
2
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -71,13 +71,15 @@ export function memorySource({ data, columns }) {
71
71
  }
72
72
 
73
73
  /**
74
- * Wraps a data source that caches all accessed rows in memory
74
+ * Wraps a data source, memoizing accessed cells. The row cache is a WeakMap
75
+ * keyed on row identity so entries are collectible once the row is unreachable,
76
+ * keeping a streaming scan O(1) instead of O(rows).
75
77
  * @param {AsyncDataSource} source
76
78
  * @returns {AsyncDataSource}
77
79
  */
78
80
  export function cachedDataSource(source) {
79
- /** @type {Map<string, Promise<SqlPrimitive>>} */
80
- const cache = new Map()
81
+ /** @type {WeakMap<object, Map<string, Promise<SqlPrimitive>>>} */
82
+ const cache = new WeakMap()
81
83
  return {
82
84
  ...source,
83
85
  scan(options) {
@@ -90,68 +92,38 @@ export function cachedDataSource(source) {
90
92
  return { rows, appliedWhere, appliedLimitOffset }
91
93
  }
92
94
 
93
- // Adjust index when source applied offset so cache keys match original rows
94
- const indexOffset = appliedLimitOffset && options.offset ? options.offset : 0
95
-
96
95
  return {
97
96
  async *rows() {
98
- let index = 0
99
97
  for await (const row of rows()) {
100
98
  if (options.signal?.aborted) break
101
- const rowIndex = index + indexOffset
99
+ const anchor = row.resolved ?? row
100
+ let rowCache = cache.get(anchor)
101
+ if (!rowCache) {
102
+ rowCache = new Map()
103
+ cache.set(anchor, rowCache)
104
+ }
102
105
  /** @type {AsyncCells} */
103
106
  const cells = {}
104
107
  for (const key of row.columns) {
105
108
  const cell = row.cells[key]
106
- // Wrap the cell to cache accesses
107
109
  cells[key] = () => {
108
- const cacheKey = `${rowIndex}:${key}`
109
- let value = cache.get(cacheKey)
110
+ let value = rowCache.get(key)
110
111
  if (!value) {
111
112
  value = cell()
112
- cache.set(cacheKey, value)
113
+ rowCache.set(key, value)
113
114
  }
114
115
  return value
115
116
  }
116
117
  }
117
- yield { columns: row.columns, cells }
118
- index++
118
+ // Preserve resolved so downstream fast paths still apply.
119
+ yield { columns: row.columns, cells, resolved: row.resolved }
119
120
  }
120
121
  },
121
122
  appliedWhere,
122
123
  appliedLimitOffset,
123
124
  }
124
125
  },
125
- ...source.scanColumn && {
126
- /**
127
- * @param {ScanColumnOptions} options
128
- * @returns {AsyncIterable<ArrayLike<SqlPrimitive>>}
129
- */
130
- scanColumn(options) {
131
- const inner = source.scanColumn(options)
132
- const indexOffset = options.offset ?? 0
133
- return (async function* () {
134
- let chunkStart = 0
135
- for await (const chunk of inner) {
136
- if (options.signal?.aborted) break
137
- /** @type {SqlPrimitive[]} */
138
- const cached = new Array(chunk.length)
139
- for (let i = 0; i < chunk.length; i++) {
140
- const cacheKey = `${chunkStart + i + indexOffset}:${options.column}`
141
- const existing = cache.get(cacheKey)
142
- if (existing) {
143
- cached[i] = await existing
144
- } else {
145
- const value = chunk[i]
146
- cache.set(cacheKey, Promise.resolve(value))
147
- cached[i] = value
148
- }
149
- }
150
- yield cached
151
- chunkStart += chunk.length
152
- }
153
- })()
154
- },
155
- },
126
+ // scanColumn passes through from ...source unwrapped: column values are
127
+ // already materialized, so caching them only retained memory unboundedly.
156
128
  }
157
129
  }
@@ -95,6 +95,7 @@ export function executeHashAggregate(plan, context) {
95
95
  }
96
96
  allRows.push(row)
97
97
  }
98
+ context.signal?.throwIfAborted()
98
99
 
99
100
  // Group rows by GROUP BY keys.
100
101
  // Each chunk dispatches all per-row key evaluations in parallel so
@@ -211,6 +212,7 @@ export function executeScalarAggregate(plan, context) {
211
212
  }
212
213
  group.push(row)
213
214
  }
215
+ context.signal?.throwIfAborted()
214
216
 
215
217
  const asyncRow = projectAggregateColumns(plan.columns, group, context)
216
218
 
@@ -282,9 +284,36 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
282
284
  /** @type {AsyncCells} */
283
285
  const cells = {}
284
286
 
287
+ // Group specs by column so each column is scanned at most once no matter how
288
+ // many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
289
+ /** @type {Map<string, ColumnAggSpec[]>} */
290
+ const specsByColumn = new Map()
291
+ for (const spec of specs) {
292
+ const group = specsByColumn.get(spec.column)
293
+ if (group) group.push(spec)
294
+ else specsByColumn.set(spec.column, [spec])
295
+ }
296
+
297
+ // Each column's single pass is computed once and shared by all its cells;
298
+ // a column is only scanned if one of its aggregates is actually read.
299
+ /** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
300
+ const passes = new Map()
301
+ /**
302
+ * @param {string} column
303
+ * @returns {Promise<Map<string, SqlPrimitive>>}
304
+ */
305
+ function scanOnce(column) {
306
+ let pass = passes.get(column)
307
+ if (!pass) {
308
+ pass = scanColumnGroup({ table, specs: specsByColumn.get(column) ?? [], limit, offset, signal })
309
+ passes.set(column, pass)
310
+ }
311
+ return pass
312
+ }
313
+
285
314
  for (const spec of specs) {
286
315
  columns.push(spec.alias)
287
- cells[spec.alias] = () => scanColumnAggregate({ table, spec, limit, offset, signal })
316
+ cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
288
317
  }
289
318
 
290
319
  yield { columns, cells }
@@ -316,68 +345,93 @@ function extractColumnAggSpec({ expr, alias }) {
316
345
  }
317
346
 
318
347
  /**
319
- * Scans a single column and computes an aggregate value.
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
+ /**
359
+ * Scans a column once and computes every aggregate over it in a single pass.
360
+ * All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
320
361
  *
321
362
  * @param {Object} options
322
363
  * @param {AsyncDataSource} options.table
323
- * @param {ColumnAggSpec} options.spec
364
+ * @param {ColumnAggSpec[]} options.specs - aggregates over the same column
324
365
  * @param {number} [options.limit]
325
366
  * @param {number} [options.offset]
326
367
  * @param {AbortSignal} [options.signal]
327
- * @returns {Promise<SqlPrimitive>}
368
+ * @returns {Promise<Map<string, SqlPrimitive>>} alias → aggregate value
328
369
  */
329
- async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
330
- const values = table.scanColumn({ column: spec.column, limit, offset, signal })
331
-
332
- if (spec.funcName === 'COUNT' && spec.distinct) {
333
- const seen = new Set()
334
- for await (const chunk of values) {
335
- if (signal?.aborted) return
336
- for (let i = 0; i < chunk.length; i++) {
337
- const v = chunk[i]
338
- if (v == null) continue
339
- seen.add(keyify(v))
340
- }
341
- }
342
- return seen.size
343
- }
344
-
345
- if (spec.funcName === 'COUNT') {
346
- let count = 0
347
- for await (const chunk of values) {
348
- if (signal?.aborted) return
349
- for (let i = 0; i < chunk.length; i++) {
350
- if (chunk[i] != null) count++
351
- }
352
- }
353
- return count
354
- }
355
-
356
- // SUM, AVG, MIN, MAX
357
- let sum = 0
358
- let count = 0
359
- /** @type {SqlPrimitive} */
360
- let min = null
361
- /** @type {SqlPrimitive} */
362
- let max = null
370
+ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
371
+ const { column } = specs[0]
372
+ const values = table.scanColumn({ column, limit, offset, signal })
373
+
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
+ }))
363
383
 
364
384
  for await (const chunk of values) {
365
- if (signal?.aborted) return
385
+ signal?.throwIfAborted()
366
386
  for (let i = 0; i < chunk.length; i++) {
367
387
  const v = chunk[i]
368
388
  if (v == null) continue
369
- if (min === null || v < min) min = v
370
- if (max === null || v > max) max = v
371
- const num = Number(v)
372
- if (!Number.isFinite(num)) continue
373
- sum += num
374
- count++
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
+ }
409
+ }
375
410
  }
376
411
  }
412
+ signal?.throwIfAborted()
377
413
 
378
- if (spec.funcName === 'SUM') return count === 0 ? null : sum
379
- if (spec.funcName === 'AVG') return count === 0 ? null : sum / count
380
- if (spec.funcName === 'MIN') return min
381
- if (spec.funcName === 'MAX') return max
382
- return null
414
+ /** @type {Map<string, SqlPrimitive>} */
415
+ const result = new Map()
416
+ for (const acc of accs) {
417
+ result.set(acc.spec.alias, finalizeAgg(acc))
418
+ }
419
+ return result
420
+ }
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
+ }
383
437
  }
@@ -356,16 +356,20 @@ function executeCount(plan, context) {
356
356
  numRows: 1,
357
357
  maxRows: 1,
358
358
  async *rows() {
359
- // Use source numRows if available
360
- const countPromise = table.numRows !== undefined ? Promise.resolve(table.numRows) : (async () => {
359
+ const countPromise = (async () => {
360
+ signal?.throwIfAborted()
361
+ // Use source numRows if available
362
+ if (table.numRows !== undefined) return table.numRows
363
+
361
364
  // Fall back to counting rows via scan
362
365
  let count = 0
363
366
  const { rows } = table.scan({ signal })
364
367
  // eslint-disable-next-line no-unused-vars
365
368
  for await (const _ of rows()) {
366
- if (signal?.aborted) return
369
+ signal?.throwIfAborted()
367
370
  count++
368
371
  }
372
+ signal?.throwIfAborted()
369
373
  return count
370
374
  })()
371
375
 
@@ -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()) {
50
- if (context.signal?.aborted) break
71
+ for await (const outerRow of outer.rows()) {
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,23 +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
- // Unmatched right rows for RIGHT/FULL joins
84
- if (matchedRightRows) {
85
- for (const rightRow of rightRows) {
86
- if (!matchedRightRows.has(rightRow)) {
87
- const nullLeft = createNullRow(leftCols ?? [])
88
- yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
104
+ if (context.signal?.aborted) return
105
+
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)
89
111
  }
90
112
  }
91
113
  }
@@ -165,29 +187,27 @@ export function executePositionalJoin(plan, context) {
165
187
  const leftTable = plan.leftAlias
166
188
  const rightTable = plan.rightAlias
167
189
 
168
- // Buffer both sides (required for positional join)
169
- /** @type {AsyncRow[]} */
170
- const leftRows = []
171
- for await (const row of left.rows()) {
172
- if (signal?.aborted) return
173
- leftRows.push(row)
174
- }
175
-
176
- /** @type {AsyncRow[]} */
177
- const rightRows = []
178
- for await (const row of right.rows()) {
179
- if (signal?.aborted) return
180
- rightRows.push(row)
181
- }
182
-
183
- const maxLen = Math.max(leftRows.length, rightRows.length)
184
- const leftCols = leftRows[0]?.columns ?? []
185
- const rightCols = rightRows[0]?.columns ?? []
186
-
187
- for (let i = 0; i < maxLen; i++) {
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
188
202
  if (signal?.aborted) return
189
- const leftRow = leftRows[i] ?? createNullRow(leftCols)
190
- 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
191
211
  yield mergeRows(leftRow, rightRow, leftTable, rightTable)
192
212
  }
193
213
  },
@@ -204,26 +224,59 @@ export function executePositionalJoin(plan, context) {
204
224
  export function executeHashJoin(plan, context) {
205
225
  const left = executePlan({ plan: plan.left, context })
206
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
207
233
  return {
208
234
  columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
209
235
  async *rows() {
210
236
  const leftTable = plan.leftAlias
211
237
  const rightTable = plan.rightAlias
212
- const { leftKeys, rightKeys, residual } = plan
213
-
214
- // Buffer right rows and build hash map
215
- /** @type {AsyncRow[]} */
216
- const rightRows = []
217
- for await (const row of right.rows()) {
218
- if (context.signal?.aborted) return
219
- 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)
220
256
  }
221
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.
222
261
  /** @type {Map<string | number | bigint | boolean, AsyncRow[]>} */
223
262
  const hashMap = new Map()
224
- 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)
225
278
  const keyValues = await Promise.all(
226
- rightKeys.map(node => evaluateExpr({ node, row: rightRow, context }))
279
+ buildKeys.map(node => evaluateExpr({ node, row: buildRow, context }))
227
280
  )
228
281
  // SQL semantics: NULL never equals anything, so a row with any NULL
229
282
  // join key is excluded from the hash table.
@@ -234,63 +287,59 @@ export function executeHashJoin(plan, context) {
234
287
  bucket = []
235
288
  hashMap.set(key, bucket)
236
289
  }
237
- bucket.push(rightRow)
290
+ bucket.push(buildRow)
238
291
  }
239
292
 
240
- // Get column info for NULL row generation
241
- const rightCols = rightRows.length ? rightRows[0].columns : []
242
-
243
293
  /** @type {string[] | undefined} */
244
- let leftCols
294
+ let probeCols
245
295
  /** @type {Set<AsyncRow> | undefined} */
246
- const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : undefined
296
+ const matchedBuildRows = buildOuter ? new Set() : undefined
247
297
 
248
- // Probe phase: stream left rows
249
- let innerCount = 0
250
- for await (const leftRow of left.rows()) {
251
- if (context.signal?.aborted) break
298
+ // Probe phase: stream the other side
299
+ for await (const probeRow of probe.rows()) {
300
+ if (context.signal?.aborted) return
252
301
 
253
- if (!leftCols) {
254
- leftCols = leftRow.columns
302
+ if (!probeCols) {
303
+ probeCols = probeRow.columns
255
304
  }
256
305
 
257
306
  const keyValues = await Promise.all(
258
- leftKeys.map(node => evaluateExpr({ node, row: leftRow, context }))
307
+ probeKeys.map(node => evaluateExpr({ node, row: probeRow, context }))
259
308
  )
260
309
  let matched = false
261
310
  if (!keyValues.some(v => v == null)) {
262
311
  const key = keyify(...keyValues)
263
312
  const candidates = hashMap.get(key)
264
313
  if (candidates?.length) {
265
- for (const rightRow of candidates) {
314
+ for (const buildRow of candidates) {
266
315
  if (++innerCount % YIELD_INTERVAL === 0) {
267
316
  await yieldToEventLoop()
268
317
  if (context.signal?.aborted) return
269
318
  }
270
- const merged = mergeRows(leftRow, rightRow, leftTable, rightTable)
319
+ const merged = merge(probeRow, buildRow)
271
320
  if (residual) {
272
321
  const ok = await evaluateExpr({ node: residual, row: merged, context })
273
322
  if (!ok) continue
274
323
  }
275
324
  matched = true
276
- matchedRightRows?.add(rightRow)
325
+ matchedBuildRows?.add(buildRow)
277
326
  yield merged
278
327
  }
279
328
  }
280
329
  }
281
330
 
282
- if (!matched && (plan.joinType === 'LEFT' || plan.joinType === 'FULL')) {
283
- const nullRight = createNullRow(rightCols)
284
- yield mergeRows(leftRow, nullRight, leftTable, rightTable)
331
+ if (!matched && probeOuter) {
332
+ yield merge(probeRow, createNullRow(buildCols))
285
333
  }
286
334
  }
287
335
 
288
- // Unmatched right rows for RIGHT/FULL joins
289
- if (matchedRightRows) {
290
- for (const rightRow of rightRows) {
291
- if (!matchedRightRows.has(rightRow)) {
292
- const nullLeft = createNullRow(leftCols ?? [])
293
- yield mergeRows(nullLeft, rightRow, leftTable, rightTable)
336
+ if (context.signal?.aborted) return
337
+
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)
294
343
  }
295
344
  }
296
345
  }
@@ -124,25 +124,47 @@ export async function sortEntriesByTerms({ entries, orderBy, context, cacheValue
124
124
  */
125
125
  export function executeSort(plan, context) {
126
126
  const child = executePlan({ plan: plan.child, context })
127
+ const { topK } = plan
128
+ // With a LIMIT bound pushed into the sort, keep at most this many buffered
129
+ // rows: periodically sort and discard everything past topK, so memory is
130
+ // bounded by the limit instead of the input size.
131
+ const bufferLimit = topK === undefined ? Infinity : Math.max(topK * 2, 1024)
127
132
  return {
128
133
  columns: child.columns,
129
- numRows: child.numRows,
130
- maxRows: child.maxRows,
134
+ numRows: topK === undefined || child.numRows === undefined
135
+ ? child.numRows
136
+ : Math.min(child.numRows, topK),
137
+ maxRows: topK === undefined
138
+ ? child.maxRows
139
+ : Math.min(child.maxRows ?? Infinity, topK),
131
140
  async *rows() {
132
- // Buffer all rows
133
- /** @type {AsyncRow[]} */
134
- const rows = []
141
+ /** @type {SortEntry[]} */
142
+ let entries = []
135
143
  for await (const row of child.rows()) {
136
144
  if (context.signal?.aborted) return
137
- rows.push(row)
145
+ entries.push({ row })
146
+ if (entries.length >= bufferLimit) {
147
+ // Sort keys are cached on the rows, so survivors of one truncation
148
+ // are not re-evaluated by the next
149
+ const sorted = await sortEntriesByTerms({
150
+ entries,
151
+ orderBy: plan.orderBy,
152
+ context,
153
+ cacheValues: true,
154
+ })
155
+ entries = sorted.slice(0, topK).map(({ row }) => ({ row }))
156
+ }
138
157
  }
139
158
 
140
- const sortedRows = await sortEntriesByTerms({
141
- entries: rows.map(row => ({ row })),
159
+ let sortedRows = await sortEntriesByTerms({
160
+ entries,
142
161
  orderBy: plan.orderBy,
143
162
  context,
144
163
  cacheValues: true,
145
164
  })
165
+ if (topK !== undefined && sortedRows.length > topK) {
166
+ sortedRows = sortedRows.slice(0, topK)
167
+ }
146
168
 
147
169
  // Yield sorted rows
148
170
  for (const { row } of sortedRows) {
package/src/plan/plan.js CHANGED
@@ -88,6 +88,7 @@ function planSetOperation({ compound, ctePlans, cteColumns, tables, parentColumn
88
88
  plan = { type: 'Sort', orderBy: compound.orderBy, child: plan }
89
89
  }
90
90
  if (compound.limit !== undefined || compound.offset) {
91
+ if (compound.limit !== undefined) pushLimitIntoSort(plan, compound.limit, compound.offset)
91
92
  plan = { type: 'Limit', limit: compound.limit, offset: compound.offset, child: plan }
92
93
  }
93
94
 
@@ -273,6 +274,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
273
274
 
274
275
  // LIMIT/OFFSET
275
276
  if (select.limit !== undefined || select.offset) {
277
+ if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
276
278
  plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
277
279
  }
278
280
  } else {
@@ -321,6 +323,7 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
321
323
  }
322
324
 
323
325
  if (!(isOwnScan && !needsBuffering && !select.distinct) && (select.limit !== undefined || select.offset)) {
326
+ if (select.limit !== undefined) pushLimitIntoSort(plan, select.limit, select.offset)
324
327
  plan = { type: 'Limit', limit: select.limit, offset: select.offset, child: plan }
325
328
  }
326
329
  }
@@ -328,6 +331,24 @@ function planSelect({ select, ctePlans, cteColumns, tables, parentColumns, outer
328
331
  return plan
329
332
  }
330
333
 
334
+ /**
335
+ * Pushes a LIMIT bound into a Sort node below, descending only through
336
+ * row-preserving Project nodes, so the sort can discard rows beyond
337
+ * LIMIT + OFFSET while sorting. A Distinct between blocks the pushdown,
338
+ * since deduplication after truncation could need more input rows.
339
+ *
340
+ * @param {QueryPlan} plan
341
+ * @param {number} limit
342
+ * @param {number} [offset]
343
+ */
344
+ function pushLimitIntoSort(plan, limit, offset) {
345
+ let node = plan
346
+ while (node.type === 'Project') node = node.child
347
+ if (node.type === 'Sort') {
348
+ node.topK = limit + (offset ?? 0)
349
+ }
350
+ }
351
+
331
352
  /**
332
353
  * @param {object} options
333
354
  * @param {SelectStatement} options.select
@@ -54,6 +54,9 @@ export interface ProjectNode {
54
54
  export interface SortNode {
55
55
  type: 'Sort'
56
56
  orderBy: OrderByItem[]
57
+ // Upper bound on rows needed from this sort (LIMIT + OFFSET pushed down
58
+ // at plan time), letting the executor discard rows beyond it while sorting
59
+ topK?: number
57
60
  child: QueryPlan
58
61
  }
59
62