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 +2 -2
- package/src/execute/accumulator.js +89 -0
- package/src/execute/aggregates.js +24 -59
- package/src/execute/join.js +123 -78
- package/src/execute/sort.js +37 -10
- package/src/execute/streamingAggregate.js +603 -0
- package/src/expression/evaluate.js +1 -1
- package/src/plan/columns.js +1 -1
- package/src/plan/plan.js +60 -1
- 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.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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
}
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
entries
|
|
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) {
|