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 +2 -2
- package/src/execute/join.js +123 -78
- package/src/execute/sort.js +30 -8
- package/src/plan/plan.js +21 -0
- package/src/plan/types.d.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.12.
|
|
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
|
|
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",
|
package/src/execute/join.js
CHANGED
|
@@ -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
|
|
55
|
+
// Buffer the inner side
|
|
34
56
|
/** @type {AsyncRow[]} */
|
|
35
|
-
const
|
|
36
|
-
for await (const row of
|
|
57
|
+
const innerRows = []
|
|
58
|
+
for await (const row of inner.rows()) {
|
|
37
59
|
if (context.signal?.aborted) return
|
|
38
|
-
|
|
60
|
+
innerRows.push(row)
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
const
|
|
63
|
+
const innerCols = innerRows.length ? innerRows[0].columns : []
|
|
42
64
|
|
|
43
65
|
/** @type {string[] | undefined} */
|
|
44
|
-
let
|
|
66
|
+
let outerCols = undefined
|
|
45
67
|
/** @type {Set<AsyncRow> | undefined} */
|
|
46
|
-
const
|
|
68
|
+
const matchedInnerRows = innerOuter ? new Set() : undefined
|
|
47
69
|
|
|
48
70
|
let innerCount = 0
|
|
49
|
-
for await (const
|
|
71
|
+
for await (const outerRow of outer.rows()) {
|
|
50
72
|
if (context.signal?.aborted) return
|
|
51
73
|
|
|
52
|
-
if (!
|
|
53
|
-
|
|
74
|
+
if (!outerCols) {
|
|
75
|
+
outerCols = outerRow.columns
|
|
54
76
|
}
|
|
55
77
|
|
|
56
78
|
let hasMatch = false
|
|
57
79
|
|
|
58
|
-
for (const
|
|
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 =
|
|
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
|
-
|
|
94
|
+
matchedInnerRows?.add(innerRow)
|
|
73
95
|
yield tempMerged
|
|
74
96
|
}
|
|
75
97
|
}
|
|
76
98
|
|
|
77
|
-
if (!hasMatch &&
|
|
78
|
-
|
|
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
|
|
86
|
-
if (
|
|
87
|
-
for (const
|
|
88
|
-
if (!
|
|
89
|
-
|
|
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
|
-
//
|
|
171
|
-
|
|
172
|
-
const leftRows =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
294
|
+
let probeCols
|
|
247
295
|
/** @type {Set<AsyncRow> | undefined} */
|
|
248
|
-
const
|
|
296
|
+
const matchedBuildRows = buildOuter ? new Set() : undefined
|
|
249
297
|
|
|
250
|
-
// Probe phase: stream
|
|
251
|
-
|
|
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 (!
|
|
256
|
-
|
|
302
|
+
if (!probeCols) {
|
|
303
|
+
probeCols = probeRow.columns
|
|
257
304
|
}
|
|
258
305
|
|
|
259
306
|
const keyValues = await Promise.all(
|
|
260
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
325
|
+
matchedBuildRows?.add(buildRow)
|
|
279
326
|
yield merged
|
|
280
327
|
}
|
|
281
328
|
}
|
|
282
329
|
}
|
|
283
330
|
|
|
284
|
-
if (!matched &&
|
|
285
|
-
|
|
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
|
|
293
|
-
if (
|
|
294
|
-
for (const
|
|
295
|
-
if (!
|
|
296
|
-
|
|
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
|
}
|
package/src/execute/sort.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
entries
|
|
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
|
package/src/plan/types.d.ts
CHANGED
|
@@ -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
|
|