squirreling 0.12.25 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.25",
3
+ "version": "0.12.26",
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",
@@ -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
  }
@@ -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