squirreling 0.12.24 → 0.12.25
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 +6 -2
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.25",
|
|
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.0.1",
|
|
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
|
@@ -47,7 +47,7 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
47
47
|
|
|
48
48
|
let innerCount = 0
|
|
49
49
|
for await (const leftRow of left.rows()) {
|
|
50
|
-
if (context.signal?.aborted)
|
|
50
|
+
if (context.signal?.aborted) return
|
|
51
51
|
|
|
52
52
|
if (!leftCols) {
|
|
53
53
|
leftCols = leftRow.columns
|
|
@@ -80,6 +80,8 @@ export function executeNestedLoopJoin(plan, context) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
if (context.signal?.aborted) return
|
|
84
|
+
|
|
83
85
|
// Unmatched right rows for RIGHT/FULL joins
|
|
84
86
|
if (matchedRightRows) {
|
|
85
87
|
for (const rightRow of rightRows) {
|
|
@@ -248,7 +250,7 @@ export function executeHashJoin(plan, context) {
|
|
|
248
250
|
// Probe phase: stream left rows
|
|
249
251
|
let innerCount = 0
|
|
250
252
|
for await (const leftRow of left.rows()) {
|
|
251
|
-
if (context.signal?.aborted)
|
|
253
|
+
if (context.signal?.aborted) return
|
|
252
254
|
|
|
253
255
|
if (!leftCols) {
|
|
254
256
|
leftCols = leftRow.columns
|
|
@@ -285,6 +287,8 @@ export function executeHashJoin(plan, context) {
|
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
if (context.signal?.aborted) return
|
|
291
|
+
|
|
288
292
|
// Unmatched right rows for RIGHT/FULL joins
|
|
289
293
|
if (matchedRightRows) {
|
|
290
294
|
for (const rightRow of rightRows) {
|