squirreling 0.14.0 → 0.15.0
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 +4 -4
- package/src/execute/aggregates.js +109 -54
- package/src/execute/execute.js +41 -17
- package/src/execute/scanColumn.js +17 -0
- package/src/types.d.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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": "26.1.
|
|
42
|
+
"@types/node": "26.1.1",
|
|
43
43
|
"@vitest/coverage-v8": "4.1.10",
|
|
44
44
|
"eslint": "9.39.4",
|
|
45
|
-
"eslint-plugin-jsdoc": "63.0.
|
|
46
|
-
"typescript": "
|
|
45
|
+
"eslint-plugin-jsdoc": "63.0.13",
|
|
46
|
+
"typescript": "7.0.2",
|
|
47
47
|
"vitest": "4.1.10"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { derivedAlias } from '../expression/alias.js'
|
|
2
2
|
import { evaluateExpr } from '../expression/evaluate.js'
|
|
3
3
|
import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
|
|
4
|
-
import { executePlan, selectColumnNames } from './execute.js'
|
|
4
|
+
import { executePlan, executeScan, selectColumnNames } from './execute.js'
|
|
5
|
+
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
5
6
|
import { sortEntriesByTerms } from './sort.js'
|
|
6
7
|
import { planStreamingAggregates, streamingHashAggregateRows, streamingScalarAggregateRows } from './streamingAggregate.js'
|
|
7
8
|
import { keyify } from './utils.js'
|
|
@@ -195,17 +196,21 @@ export function executeHashAggregate(plan, context) {
|
|
|
195
196
|
*/
|
|
196
197
|
export function executeScalarAggregate(plan, context) {
|
|
197
198
|
// Fast path: use scanColumn when available
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
199
|
+
const columnScan = tryColumnScanAggregate(plan, context)
|
|
200
|
+
if (columnScan?.rows) {
|
|
200
201
|
return {
|
|
201
202
|
columns: selectColumnNames(plan.columns, []),
|
|
202
203
|
numRows: 1,
|
|
203
204
|
maxRows: 1,
|
|
204
|
-
rows:
|
|
205
|
+
rows: columnScan.rows,
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
|
|
209
|
+
// A declined pushdown still returned a usable one-column scan. Transfer it
|
|
210
|
+
// to the ordinary scan path instead of calling the source a second time.
|
|
211
|
+
const child = columnScan?.fallback
|
|
212
|
+
? executeScan(columnScan.fallback.plan, context, columnScan.fallback.result)
|
|
213
|
+
: executePlan({ plan: plan.child, context })
|
|
209
214
|
const streaming = planStreamingAggregates(plan, child.columns)
|
|
210
215
|
if (streaming) {
|
|
211
216
|
return {
|
|
@@ -263,6 +268,7 @@ export function executeScalarAggregate(plan, context) {
|
|
|
263
268
|
* column: string,
|
|
264
269
|
* alias: string,
|
|
265
270
|
* distinct?: boolean,
|
|
271
|
+
* star?: boolean,
|
|
266
272
|
* }} ColumnAggSpec
|
|
267
273
|
*/
|
|
268
274
|
|
|
@@ -272,7 +278,13 @@ export function executeScalarAggregate(plan, context) {
|
|
|
272
278
|
*
|
|
273
279
|
* @param {ScalarAggregateNode} plan
|
|
274
280
|
* @param {ExecuteContext} context
|
|
275
|
-
* @returns {
|
|
281
|
+
* @returns {{
|
|
282
|
+
* rows?: () => AsyncGenerator<AsyncRow>,
|
|
283
|
+
* fallback?: {
|
|
284
|
+
* plan: import('../plan/types.js').ScanNode,
|
|
285
|
+
* result: import('../types.js').ScanColumnResults,
|
|
286
|
+
* },
|
|
287
|
+
* } | undefined}
|
|
276
288
|
*/
|
|
277
289
|
function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
278
290
|
// No HAVING support in fast path
|
|
@@ -281,61 +293,96 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
|
281
293
|
if (plan.child.type !== 'Scan') return
|
|
282
294
|
const scanNode = plan.child
|
|
283
295
|
const { limit, offset, where } = scanNode.hints
|
|
284
|
-
// scanColumn doesn't support filtering
|
|
285
|
-
if (where) return
|
|
286
296
|
|
|
287
297
|
const table = tables[scanNode.table]
|
|
288
298
|
if (!table?.scanColumn) return
|
|
289
299
|
|
|
300
|
+
// COUNT(*) needs a physical column whose filtered chunk lengths can be
|
|
301
|
+
// counted. Prefer a predicate/projection column, then any table column.
|
|
302
|
+
const starColumn = scanNode.hints.columns?.[0] ?? table.columns[0]
|
|
303
|
+
if (!starColumn) return
|
|
304
|
+
|
|
290
305
|
// All columns must be simple aggregates on plain identifiers
|
|
291
306
|
/** @type {ColumnAggSpec[]} */
|
|
292
307
|
const specs = []
|
|
293
308
|
for (const col of plan.columns) {
|
|
294
309
|
if (col.type !== 'derived') return
|
|
295
|
-
const spec = extractColumnAggSpec(col)
|
|
310
|
+
const spec = extractColumnAggSpec(col, starColumn)
|
|
296
311
|
if (!spec) return
|
|
297
312
|
specs.push(spec)
|
|
298
313
|
}
|
|
299
314
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
const physicalColumns = new Set(specs.map(spec => spec.column))
|
|
316
|
+
const hasPushdown = where || limit !== undefined || offset !== undefined
|
|
317
|
+
if (hasPushdown) {
|
|
318
|
+
// If negotiation fails, the returned column stream can only replace the
|
|
319
|
+
// ordinary scan when that scan needs the same single column. Otherwise a
|
|
320
|
+
// probe could allocate work that no correct fallback is able to consume.
|
|
321
|
+
const scanColumns = scanNode.hints.columns
|
|
322
|
+
if (physicalColumns.size !== 1 || scanColumns?.length !== 1 ||
|
|
323
|
+
!physicalColumns.has(scanColumns[0])) return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Ask once per physical column and retain the returned chunks. Sources
|
|
327
|
+
// report applied hints just like scan(); declined hints use the normal path.
|
|
328
|
+
/** @type {Map<string, import('../types.js').ScanColumnResults>} */
|
|
329
|
+
const columnScans = new Map()
|
|
330
|
+
for (const { column } of specs) {
|
|
331
|
+
if (columnScans.has(column)) continue
|
|
332
|
+
const options = { column, where, limit, offset, signal }
|
|
333
|
+
const result = normalizeScanColumnResult(table.scanColumn(options), options)
|
|
334
|
+
if (where && !result.appliedWhere) {
|
|
335
|
+
return { fallback: { plan: scanNode, result } }
|
|
336
|
+
}
|
|
337
|
+
if ((limit !== undefined || offset !== undefined) && !result.appliedLimitOffset) {
|
|
338
|
+
return { fallback: { plan: scanNode, result } }
|
|
314
339
|
}
|
|
340
|
+
columnScans.set(column, result)
|
|
341
|
+
}
|
|
315
342
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
343
|
+
return {
|
|
344
|
+
async *rows() {
|
|
345
|
+
/** @type {string[]} */
|
|
346
|
+
const columns = []
|
|
347
|
+
/** @type {AsyncCells} */
|
|
348
|
+
const cells = {}
|
|
349
|
+
|
|
350
|
+
// Group specs by column so each column is scanned at most once no matter how
|
|
351
|
+
// many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
|
|
352
|
+
/** @type {Map<string, ColumnAggSpec[]>} */
|
|
353
|
+
const specsByColumn = new Map()
|
|
354
|
+
for (const spec of specs) {
|
|
355
|
+
const group = specsByColumn.get(spec.column)
|
|
356
|
+
if (group) group.push(spec)
|
|
357
|
+
else specsByColumn.set(spec.column, [spec])
|
|
329
358
|
}
|
|
330
|
-
return pass
|
|
331
|
-
}
|
|
332
359
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
360
|
+
// Each column's single pass is computed once and shared by all its cells;
|
|
361
|
+
// a column is only scanned if one of its aggregates is actually read.
|
|
362
|
+
/** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
|
|
363
|
+
const passes = new Map()
|
|
364
|
+
/**
|
|
365
|
+
* @param {string} column
|
|
366
|
+
* @returns {Promise<Map<string, SqlPrimitive>>}
|
|
367
|
+
*/
|
|
368
|
+
function scanOnce(column) {
|
|
369
|
+
let pass = passes.get(column)
|
|
370
|
+
if (!pass) {
|
|
371
|
+
const scan = columnScans.get(column)
|
|
372
|
+
if (!scan) throw new Error(`missing scanColumn result for ${column}`)
|
|
373
|
+
pass = scanColumnGroup({ values: scan.chunks(), specs: specsByColumn.get(column) ?? [], signal })
|
|
374
|
+
passes.set(column, pass)
|
|
375
|
+
}
|
|
376
|
+
return pass
|
|
377
|
+
}
|
|
337
378
|
|
|
338
|
-
|
|
379
|
+
for (const spec of specs) {
|
|
380
|
+
columns.push(spec.alias)
|
|
381
|
+
cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
yield { columns, cells }
|
|
385
|
+
},
|
|
339
386
|
}
|
|
340
387
|
}
|
|
341
388
|
|
|
@@ -344,16 +391,27 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
|
|
|
344
391
|
* Returns undefined if the expression is not a supported simple aggregate.
|
|
345
392
|
*
|
|
346
393
|
* @param {DerivedColumn} col
|
|
394
|
+
* @param {string} starColumn
|
|
347
395
|
* @returns {ColumnAggSpec | undefined}
|
|
348
396
|
*/
|
|
349
|
-
function extractColumnAggSpec({ expr, alias }) {
|
|
397
|
+
function extractColumnAggSpec({ expr, alias }, starColumn) {
|
|
350
398
|
if (expr.type !== 'function') return
|
|
351
399
|
if (expr.filter) return // FILTER not supported in fast path
|
|
352
400
|
const funcName = expr.funcName.toUpperCase()
|
|
353
401
|
if (!['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(funcName)) return
|
|
354
402
|
|
|
355
|
-
// Argument must be a plain column identifier
|
|
403
|
+
// Argument must be a plain column identifier, except COUNT(*), which counts
|
|
404
|
+
// the lengths of filtered chunks from an arbitrary physical column.
|
|
356
405
|
const arg = expr.args[0]
|
|
406
|
+
if (arg.type === 'star') {
|
|
407
|
+
if (funcName !== 'COUNT' || expr.distinct) return
|
|
408
|
+
return {
|
|
409
|
+
funcName,
|
|
410
|
+
column: starColumn,
|
|
411
|
+
alias: alias ?? derivedAlias(expr),
|
|
412
|
+
star: true,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
357
415
|
if (arg.type !== 'identifier') return
|
|
358
416
|
return {
|
|
359
417
|
funcName,
|
|
@@ -368,26 +426,23 @@ function extractColumnAggSpec({ expr, alias }) {
|
|
|
368
426
|
* All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
|
|
369
427
|
*
|
|
370
428
|
* @param {Object} options
|
|
371
|
-
* @param {
|
|
429
|
+
* @param {AsyncIterable<ArrayLike<SqlPrimitive>>} options.values
|
|
372
430
|
* @param {ColumnAggSpec[]} options.specs - aggregates over the same column
|
|
373
|
-
* @param {number} [options.limit]
|
|
374
|
-
* @param {number} [options.offset]
|
|
375
431
|
* @param {AbortSignal} [options.signal]
|
|
376
432
|
* @returns {Promise<Map<string, SqlPrimitive>>} alias → aggregate value
|
|
377
433
|
*/
|
|
378
|
-
async function scanColumnGroup({
|
|
379
|
-
const { column } = specs[0]
|
|
380
|
-
const values = table.scanColumn({ column, limit, offset, signal })
|
|
381
|
-
|
|
434
|
+
async function scanColumnGroup({ values, specs, signal }) {
|
|
382
435
|
const accs = specs.map(spec => ({ spec, acc: newAccumulator(spec.funcName, spec.distinct) }))
|
|
383
436
|
|
|
384
437
|
for await (const chunk of values) {
|
|
385
438
|
signal?.throwIfAborted()
|
|
386
439
|
for (let i = 0; i < chunk.length; i++) {
|
|
387
440
|
const v = chunk[i]
|
|
388
|
-
if (v == null) continue
|
|
389
441
|
for (const { spec, acc } of accs) {
|
|
390
|
-
|
|
442
|
+
// COUNT(*) counts matching rows even when the arbitrary carrier column
|
|
443
|
+
// selected for scanColumn is null. Other aggregates ignore nulls.
|
|
444
|
+
if (spec.star) acc.count++
|
|
445
|
+
else if (v != null) updateAccumulator(spec.funcName, acc, v)
|
|
391
446
|
}
|
|
392
447
|
}
|
|
393
448
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -7,6 +7,7 @@ import { statementScope } from '../plan/columns.js'
|
|
|
7
7
|
import { validateScan, validateTable } from '../validation/tables.js'
|
|
8
8
|
import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
|
|
9
9
|
import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
|
|
10
|
+
import { normalizeScanColumnResult } from './scanColumn.js'
|
|
10
11
|
import { executeSort } from './sort.js'
|
|
11
12
|
import { addBounds, minBounds, stableRowKey } from './utils.js'
|
|
12
13
|
import { executeWindow } from './window.js'
|
|
@@ -269,40 +270,63 @@ export function selectColumnNames(selectColumns, childColumns) {
|
|
|
269
270
|
/**
|
|
270
271
|
* @param {ScanNode} plan
|
|
271
272
|
* @param {ExecuteContext} context
|
|
273
|
+
* @param {import('../types.js').ScanColumnResults} [existingColumnResult]
|
|
272
274
|
* @returns {QueryResults}
|
|
273
275
|
*/
|
|
274
|
-
function executeScan(plan, context) {
|
|
276
|
+
export function executeScan(plan, context, existingColumnResult) {
|
|
275
277
|
const { tables, signal } = context
|
|
276
278
|
const table = validateTable({ ...plan, tables })
|
|
277
279
|
validateScan({ ...plan, tables })
|
|
278
280
|
const hasLimitOffset = plan.hints.limit !== undefined || plan.hints.offset // 0 offset is noop
|
|
279
281
|
|
|
280
|
-
// Fast path: single column scan
|
|
281
|
-
|
|
282
|
+
// Fast path: single column scan. As with scan(), hints the source did not
|
|
283
|
+
// apply are handled by the engine over the returned column values.
|
|
284
|
+
const scanColumnOptions = plan.hints.columns?.length === 1
|
|
285
|
+
? plan.hints.where
|
|
286
|
+
// Do not push a filtered range until WHERE is known to be applied: an
|
|
287
|
+
// older source may ignore WHERE but eagerly apply LIMIT/OFFSET.
|
|
288
|
+
? { column: plan.hints.columns[0], where: plan.hints.where, signal }
|
|
289
|
+
: { column: plan.hints.columns[0], ...plan.hints, signal }
|
|
290
|
+
: undefined
|
|
291
|
+
const columnResult = existingColumnResult ?? (table.scanColumn && scanColumnOptions
|
|
292
|
+
? normalizeScanColumnResult(table.scanColumn(scanColumnOptions), scanColumnOptions)
|
|
293
|
+
: undefined)
|
|
294
|
+
if (columnResult && scanColumnOptions) {
|
|
282
295
|
const column = plan.hints.columns[0]
|
|
283
|
-
const chunks = table.scanColumn({
|
|
284
|
-
column,
|
|
285
|
-
limit: plan.hints.limit,
|
|
286
|
-
offset: plan.hints.offset,
|
|
287
|
-
signal,
|
|
288
|
-
})
|
|
289
296
|
const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
|
|
290
297
|
return {
|
|
291
298
|
columns: [column],
|
|
292
|
-
numRows: scanRows,
|
|
299
|
+
numRows: plan.hints.where ? undefined : scanRows,
|
|
293
300
|
maxRows: scanRows,
|
|
294
301
|
async *rows() {
|
|
295
302
|
const columns = [column]
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
+
let result = (async function* () {
|
|
304
|
+
// Creating the chunk stream may itself start I/O, so leave it until
|
|
305
|
+
// the returned rows are actually consumed.
|
|
306
|
+
for await (const chunk of columnResult.chunks()) {
|
|
307
|
+
signal?.throwIfAborted()
|
|
308
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
309
|
+
const value = chunk[i]
|
|
310
|
+
yield {
|
|
311
|
+
columns,
|
|
312
|
+
cells: { [column]: () => Promise.resolve(value) },
|
|
313
|
+
}
|
|
303
314
|
}
|
|
304
315
|
}
|
|
316
|
+
})()
|
|
317
|
+
|
|
318
|
+
if (!columnResult.appliedWhere && plan.hints.where) {
|
|
319
|
+
result = filterRows(result, plan.hints.where, context, plan.hints.limit)
|
|
305
320
|
}
|
|
321
|
+
// Filtered LIMIT/OFFSET was intentionally not passed to scanColumn.
|
|
322
|
+
const appliedLimitOffset = plan.hints.where
|
|
323
|
+
? false
|
|
324
|
+
: columnResult.appliedLimitOffset
|
|
325
|
+
if (!appliedLimitOffset && hasLimitOffset) {
|
|
326
|
+
result = limitRows(result, plan.hints.limit, plan.hints.offset, signal)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
yield* result
|
|
306
330
|
// A data source may end its stream cooperatively on abort; surface
|
|
307
331
|
// the abort so a truncated scan is not mistaken for a complete one
|
|
308
332
|
signal?.throwIfAborted()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes scanColumn's legacy AsyncIterable return into ScanColumnResults.
|
|
3
|
+
* Legacy implementations are valid for unfiltered scans only: they cannot
|
|
4
|
+
* claim to have applied a WHERE predicate they predate.
|
|
5
|
+
*
|
|
6
|
+
* @param {AsyncIterable<ArrayLike<import('../types.js').SqlPrimitive>> | import('../types.js').ScanColumnResults} result
|
|
7
|
+
* @param {import('../types.js').ScanColumnOptions} options
|
|
8
|
+
* @returns {import('../types.js').ScanColumnResults}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeScanColumnResult(result, options) {
|
|
11
|
+
if ('chunks' in result) return result
|
|
12
|
+
return {
|
|
13
|
+
chunks: () => result,
|
|
14
|
+
appliedWhere: !options.where,
|
|
15
|
+
appliedLimitOffset: !options.where,
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface AsyncDataSource {
|
|
|
79
79
|
columns: string[]
|
|
80
80
|
scan(options: ScanOptions): ScanResults
|
|
81
81
|
// Optional method for fast column scans
|
|
82
|
-
scanColumn?(options: ScanColumnOptions): AsyncIterable<ArrayLike<SqlPrimitive>>
|
|
82
|
+
scanColumn?(options: ScanColumnOptions): AsyncIterable<ArrayLike<SqlPrimitive>> | ScanColumnResults
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
@@ -113,11 +113,19 @@ export interface ScanOptions {
|
|
|
113
113
|
*/
|
|
114
114
|
export interface ScanColumnOptions {
|
|
115
115
|
column: string
|
|
116
|
+
where?: ExprNode
|
|
116
117
|
limit?: number
|
|
117
118
|
offset?: number
|
|
118
119
|
signal?: AbortSignal
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/** Result of a column scan, mirroring the ScanResults hint flags. */
|
|
123
|
+
export interface ScanColumnResults {
|
|
124
|
+
chunks(): AsyncIterable<ArrayLike<SqlPrimitive>>
|
|
125
|
+
appliedWhere: boolean
|
|
126
|
+
appliedLimitOffset: boolean
|
|
127
|
+
}
|
|
128
|
+
|
|
121
129
|
export interface FunctionSignature {
|
|
122
130
|
min: number
|
|
123
131
|
max?: number
|