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 +2 -0
- package/package.json +5 -5
- package/src/backend/dataSource.js +18 -46
- package/src/execute/aggregates.js +104 -50
- package/src/execute/execute.js +7 -3
- package/src/execute/join.js +129 -80
- package/src/execute/sort.js +30 -8
- package/src/plan/plan.js +21 -0
- package/src/plan/types.d.ts +3 -0
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.
|
|
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": "
|
|
43
|
-
"@vitest/coverage-v8": "4.1.
|
|
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.
|
|
45
|
+
"eslint-plugin-jsdoc": "63.0.10",
|
|
46
46
|
"typescript": "6.0.3",
|
|
47
|
-
"vitest": "4.1.
|
|
47
|
+
"vitest": "4.1.9"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { AsyncCells, AsyncDataSource, AsyncRow,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
109
|
-
let value = cache.get(cacheKey)
|
|
110
|
+
let value = rowCache.get(key)
|
|
110
111
|
if (!value) {
|
|
111
112
|
value = cell()
|
|
112
|
-
|
|
113
|
+
rowCache.set(key, value)
|
|
113
114
|
}
|
|
114
115
|
return value
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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] = () =>
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -356,16 +356,20 @@ function executeCount(plan, context) {
|
|
|
356
356
|
numRows: 1,
|
|
357
357
|
maxRows: 1,
|
|
358
358
|
async *rows() {
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
369
|
+
signal?.throwIfAborted()
|
|
367
370
|
count++
|
|
368
371
|
}
|
|
372
|
+
signal?.throwIfAborted()
|
|
369
373
|
return count
|
|
370
374
|
})()
|
|
371
375
|
|
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
|
|
50
|
-
if (context.signal?.aborted)
|
|
71
|
+
for await (const outerRow of outer.rows()) {
|
|
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,23 +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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
169
|
-
|
|
170
|
-
const leftRows =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
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
|
-
|
|
190
|
-
|
|
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 {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
294
|
+
let probeCols
|
|
245
295
|
/** @type {Set<AsyncRow> | undefined} */
|
|
246
|
-
const
|
|
296
|
+
const matchedBuildRows = buildOuter ? new Set() : undefined
|
|
247
297
|
|
|
248
|
-
// Probe phase: stream
|
|
249
|
-
|
|
250
|
-
|
|
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 (!
|
|
254
|
-
|
|
302
|
+
if (!probeCols) {
|
|
303
|
+
probeCols = probeRow.columns
|
|
255
304
|
}
|
|
256
305
|
|
|
257
306
|
const keyValues = await Promise.all(
|
|
258
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
325
|
+
matchedBuildRows?.add(buildRow)
|
|
277
326
|
yield merged
|
|
278
327
|
}
|
|
279
328
|
}
|
|
280
329
|
}
|
|
281
330
|
|
|
282
|
-
if (!matched &&
|
|
283
|
-
|
|
284
|
-
yield mergeRows(leftRow, nullRight, leftTable, rightTable)
|
|
331
|
+
if (!matched && probeOuter) {
|
|
332
|
+
yield merge(probeRow, createNullRow(buildCols))
|
|
285
333
|
}
|
|
286
334
|
}
|
|
287
335
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
}
|
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
|
|