squirreling 0.15.2 → 0.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -42,7 +42,7 @@
42
42
  "@types/node": "26.2.0",
43
43
  "@vitest/coverage-v8": "4.1.10",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "64.1.0",
45
+ "eslint-plugin-jsdoc": "64.2.1",
46
46
  "typescript": "7.0.2",
47
47
  "vitest": "4.1.10"
48
48
  }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * @import { AsyncBatch, ColumnResult, ColumnVector, ReadBatchColumnOptions, RowSelection, SqlPrimitive } from '../types.js'
3
+ */
4
+
5
+ /** @type {WeakMap<AsyncBatch, Map<number, Map<RowSelection, { resolved?: ColumnVector, pending: Map<AbortSignal | undefined, Promise<ColumnVector>> }>>>} */
6
+ const batchCache = new WeakMap()
7
+
8
+ /**
9
+ * Returns the number of selected rows.
10
+ *
11
+ * @param {RowSelection} selection
12
+ * @returns {number}
13
+ */
14
+ export function selectedRowCount(selection) {
15
+ if (selection.type === 'all') return selection.length
16
+ if (selection.type === 'range') return selection.end - selection.start
17
+ return selection.indices.length
18
+ }
19
+
20
+ /**
21
+ * Creates base-aligned ordinal values for the rows in a selection.
22
+ *
23
+ * @param {RowSelection} selection
24
+ * @returns {ColumnVector}
25
+ */
26
+ export function selectionOrdinals(selection) {
27
+ const values = new Uint32Array(selection.length)
28
+ const length = selectedRowCount(selection)
29
+ for (let ordinal = 0; ordinal < length; ordinal++) {
30
+ values[selectionIndexAt(selection, ordinal)] = ordinal
31
+ }
32
+ return { type: 'typed', values, length: selection.length }
33
+ }
34
+
35
+ /**
36
+ * Composes a selection over an already-selected domain with the selection
37
+ * that produced that domain.
38
+ *
39
+ * @param {RowSelection} outer - selection over the original base domain
40
+ * @param {RowSelection} inner - selection over the rows selected by `outer`
41
+ * @returns {RowSelection}
42
+ */
43
+ export function composeSelections(outer, inner) {
44
+ const outerCount = selectedRowCount(outer)
45
+ if (inner.length !== outerCount) {
46
+ throw new Error(`Cannot compose selection of length ${inner.length} over ${outerCount} rows`)
47
+ }
48
+ if (inner.type === 'all') return outer
49
+ if (outer.type === 'all') return { ...inner, length: outer.length }
50
+
51
+ if (outer.type === 'range' && inner.type === 'range') {
52
+ return {
53
+ type: 'range',
54
+ start: outer.start + inner.start,
55
+ end: outer.start + inner.end,
56
+ length: outer.length,
57
+ }
58
+ }
59
+
60
+ const indices = new Uint32Array(selectedRowCount(inner))
61
+ for (let i = 0; i < indices.length; i++) {
62
+ indices[i] = selectionIndexAt(outer, selectionIndexAt(inner, i))
63
+ }
64
+ return { type: 'indices', indices, length: outer.length }
65
+ }
66
+
67
+ /**
68
+ * Reads one logical value from a vector.
69
+ *
70
+ * @param {ColumnVector} vector
71
+ * @param {number} index
72
+ * @returns {SqlPrimitive}
73
+ */
74
+ export function valueAt(vector, index) {
75
+ if (!Number.isInteger(index) || index < 0 || index >= vector.length) {
76
+ throw new RangeError(`Column index ${index} is outside vector length ${vector.length}`)
77
+ }
78
+ if (vector.type === 'values') return vector.values[index]
79
+ if (vector.type === 'typed') {
80
+ if (vector.validity && vector.validity[index] === 0) return null
81
+ return vector.values[index]
82
+ }
83
+ if (vector.type === 'constant') return vector.value
84
+ return valueAt(vector.source, selectionIndexAt(vector.selection, index))
85
+ }
86
+
87
+ /**
88
+ * Creates a zero-copy view of a vector through a row selection.
89
+ *
90
+ * @param {ColumnVector} vector
91
+ * @param {RowSelection} selection
92
+ * @returns {ColumnVector}
93
+ */
94
+ export function selectVector(vector, selection) {
95
+ if (selection.length !== vector.length) {
96
+ throw new Error(`Cannot select ${selection.length} rows from vector length ${vector.length}`)
97
+ }
98
+ if (selection.type === 'all') return vector
99
+ if (vector.type === 'selected') {
100
+ const composed = composeSelections(vector.selection, selection)
101
+ return {
102
+ type: 'selected',
103
+ source: vector.source,
104
+ selection: composed,
105
+ length: selectedRowCount(composed),
106
+ }
107
+ }
108
+ return {
109
+ type: 'selected',
110
+ source: vector,
111
+ selection,
112
+ length: selectedRowCount(selection),
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Resolves one batch column for an effective selection.
118
+ *
119
+ * @param {ReadBatchColumnOptions} options
120
+ * @returns {ColumnResult}
121
+ */
122
+ export function readBatchColumn({ batch, columnIndex, selection = batch.selection, signal }) {
123
+ const column = batch.columns[columnIndex]
124
+ if (!column) throw new RangeError(`Column index ${columnIndex} is outside batch columns`)
125
+ if (selection.length !== batch.selection.length) {
126
+ throw new Error(`Selection length ${selection.length} does not match batch length ${batch.selection.length}`)
127
+ }
128
+ if (!('read' in column)) {
129
+ return selectVector(column, selection)
130
+ }
131
+
132
+ let batchResults = batchCache.get(batch)
133
+ if (!batchResults) {
134
+ batchResults = new Map()
135
+ batchCache.set(batch, batchResults)
136
+ }
137
+ let columnResults = batchResults.get(columnIndex)
138
+ if (!columnResults) {
139
+ columnResults = new Map()
140
+ batchResults.set(columnIndex, columnResults)
141
+ }
142
+ let cache = columnResults.get(selection)
143
+ if (!cache) {
144
+ cache = { pending: new Map() }
145
+ columnResults.set(selection, cache)
146
+ }
147
+ const pending = cache.pending.get(signal)
148
+ if (pending) return pending
149
+ if (cache.resolved) return cache.resolved
150
+
151
+ const result = column.read({
152
+ batch: column.input ?? batch,
153
+ selection,
154
+ signal,
155
+ rowOffset: column.rowOffset,
156
+ rowOrdinals: column.rowOrdinals
157
+ ? selectVector(column.rowOrdinals, selection)
158
+ : undefined,
159
+ })
160
+ const validated = validateColumnResult(result, selectedRowCount(selection))
161
+ if (validated instanceof Promise) {
162
+ const settled = validated.then(function cacheResolved(vector) {
163
+ cache.pending.delete(signal)
164
+ cache.resolved ??= vector
165
+ return vector
166
+ }, function evictRejected(error) {
167
+ cache.pending.delete(signal)
168
+ throw error
169
+ })
170
+ cache.pending.set(signal, settled)
171
+ return settled
172
+ }
173
+ cache.resolved = validated
174
+ return validated
175
+ }
176
+
177
+ /**
178
+ * Selects rows from a batch without reading or copying its columns.
179
+ *
180
+ * @param {AsyncBatch} batch
181
+ * @param {RowSelection} selection - selection over the batch's current rows
182
+ * @returns {AsyncBatch}
183
+ */
184
+ export function selectBatch(batch, selection) {
185
+ const composed = composeSelections(batch.selection, selection)
186
+ return {
187
+ selection: composed,
188
+ columns: batch.columns,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Returns a base-domain row index for a logical selected-row index.
194
+ *
195
+ * @param {RowSelection} selection
196
+ * @param {number} index
197
+ * @returns {number}
198
+ */
199
+ function selectionIndexAt(selection, index) {
200
+ const count = selectedRowCount(selection)
201
+ if (!Number.isInteger(index) || index < 0 || index >= count) {
202
+ throw new RangeError(`Selection index ${index} is outside selected length ${count}`)
203
+ }
204
+ if (selection.type === 'all') return index
205
+ if (selection.type === 'range') return selection.start + index
206
+ return selection.indices[index]
207
+ }
208
+
209
+ /**
210
+ * Validates the resolved vector length without forcing synchronous column
211
+ * implementations through a promise.
212
+ *
213
+ * @param {ColumnResult} result
214
+ * @param {number} expectedLength
215
+ * @returns {ColumnResult}
216
+ */
217
+ function validateColumnResult(result, expectedLength) {
218
+ if (result instanceof Promise) {
219
+ return result.then(function validateResolvedVector(vector) {
220
+ return validateVectorLength(vector, expectedLength)
221
+ })
222
+ }
223
+ return validateVectorLength(result, expectedLength)
224
+ }
225
+
226
+ /**
227
+ * @param {ColumnVector} vector
228
+ * @param {number} expectedLength
229
+ * @returns {ColumnVector}
230
+ */
231
+ function validateVectorLength(vector, expectedLength) {
232
+ if (vector.length !== expectedLength) {
233
+ throw new Error(`Column returned ${vector.length} rows, expected ${expectedLength}`)
234
+ }
235
+ return vector
236
+ }
@@ -0,0 +1,178 @@
1
+ import { readBatchColumn, selectVector, selectedRowCount, valueAt } from './batch.js'
2
+ import { yieldToEventLoop } from '../execute/yield.js'
3
+
4
+ /**
5
+ * @import { AsyncBatch, AsyncCells, AsyncRow, ColumnResult, ColumnVector, RowsToBatchesOptions, SqlPrimitive } from '../types.js'
6
+ */
7
+
8
+ const DEFAULT_BATCH_ROWS = 1024
9
+
10
+ /**
11
+ * Materializes a legacy row stream into aligned batches. This is a source or
12
+ * public compatibility boundary, not an operator implementation strategy.
13
+ *
14
+ * @param {AsyncIterable<AsyncRow>} rows
15
+ * @param {string[]} columnNames
16
+ * @param {RowsToBatchesOptions} [options]
17
+ * @yields {AsyncBatch}
18
+ */
19
+ export async function* rowsToBatches(rows, columnNames, options) {
20
+ const batchRows = options?.batchRows ?? DEFAULT_BATCH_ROWS
21
+ if (!Number.isInteger(batchRows) || batchRows <= 0) {
22
+ throw new RangeError(`batchRows must be a positive integer, got ${batchRows}`)
23
+ }
24
+ let values = makeValueBuffers(columnNames.length)
25
+ let rowCount = 0
26
+
27
+ for await (const row of rows) {
28
+ options?.signal?.throwIfAborted()
29
+ for (let columnIndex = 0; columnIndex < columnNames.length; columnIndex++) {
30
+ const name = columnNames[columnIndex]
31
+ values[columnIndex].push(await readRowCell(row, name))
32
+ }
33
+ rowCount++
34
+ if (rowCount === batchRows) {
35
+ yield loadedBatch(values, rowCount)
36
+ values = makeValueBuffers(columnNames.length)
37
+ rowCount = 0
38
+ }
39
+ }
40
+
41
+ if (rowCount > 0) yield loadedBatch(values, rowCount)
42
+ options?.signal?.throwIfAborted()
43
+ }
44
+
45
+ /**
46
+ * Exposes batches through the legacy lazy-cell row interface. Column reads
47
+ * remain lazy and are memoized once per batch/selection by `readBatchColumn`.
48
+ *
49
+ * @param {AsyncIterable<AsyncBatch>} batches
50
+ * @param {string[]} columns
51
+ * @param {AbortSignal} [signal]
52
+ * @yields {AsyncRow}
53
+ */
54
+ export async function* batchesToRows(batches, columns, signal) {
55
+ for await (const batch of batches) {
56
+ const loadedVectors = batch.columns.map(function loadedVector(column) {
57
+ if ('read' in column) return undefined
58
+ return selectVector(column, batch.selection)
59
+ })
60
+ const rowCount = selectedRowCount(batch.selection)
61
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
62
+ signal?.throwIfAborted()
63
+ /** @type {AsyncCells} */
64
+ const cells = {}
65
+ for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
66
+ const currentColumn = columnIndex
67
+ const currentRow = rowIndex
68
+ const loadedVector = loadedVectors[columnIndex]
69
+ if (loadedVector) {
70
+ const value = valueAt(loadedVector, currentRow)
71
+ cells[columns[columnIndex]] = function readLoadedCell() {
72
+ return Promise.resolve(value)
73
+ }
74
+ } else {
75
+ cells[columns[columnIndex]] = async function readCell() {
76
+ const vector = await readBatchColumn({ batch, columnIndex: currentColumn, signal })
77
+ return valueAt(vector, currentRow)
78
+ }
79
+ }
80
+ }
81
+ yield { columns, cells }
82
+ }
83
+ }
84
+ signal?.throwIfAborted()
85
+ }
86
+
87
+ /**
88
+ * Collects batches directly into the existing object-row result shape without
89
+ * constructing compatibility `AsyncRow` values.
90
+ *
91
+ * @param {AsyncIterable<AsyncBatch>} batches
92
+ * @param {string[]} names
93
+ * @param {AbortSignal} [signal]
94
+ * @returns {Promise<Record<string, SqlPrimitive>[]>}
95
+ */
96
+ export async function collectBatches(batches, names, signal) {
97
+ /** @type {Record<string, SqlPrimitive>[]} */
98
+ const rows = []
99
+ for await (const batch of batches) {
100
+ const results = batch.columns.map(function readColumn(_column, columnIndex) {
101
+ return readBatchColumn({ batch, columnIndex, signal })
102
+ })
103
+ const vectors = await resolveColumns(results)
104
+ const rowCount = selectedRowCount(batch.selection)
105
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
106
+ if (signal && rowIndex % 4000 === 0) {
107
+ if (rowIndex > 0) await yieldToEventLoop()
108
+ signal.throwIfAborted()
109
+ }
110
+ /** @type {Record<string, SqlPrimitive>} */
111
+ const row = {}
112
+ for (let columnIndex = 0; columnIndex < names.length; columnIndex++) {
113
+ row[names[columnIndex]] = valueAt(vectors[columnIndex], rowIndex)
114
+ }
115
+ rows.push(row)
116
+ }
117
+ }
118
+ signal?.throwIfAborted()
119
+ return rows
120
+ }
121
+
122
+ /**
123
+ * @param {AsyncRow} row
124
+ * @param {string} name
125
+ * @returns {Promise<SqlPrimitive>}
126
+ */
127
+ async function readRowCell(row, name) {
128
+ if (row.resolved && Object.prototype.hasOwnProperty.call(row.resolved, name)) {
129
+ return row.resolved[name]
130
+ }
131
+ const cell = row.cells[name]
132
+ if (!cell) throw new Error(`Row does not contain column "${name}"`)
133
+ return await cell()
134
+ }
135
+
136
+ /**
137
+ * @param {number} count
138
+ * @returns {SqlPrimitive[][]}
139
+ */
140
+ function makeValueBuffers(count) {
141
+ /** @type {SqlPrimitive[][]} */
142
+ const values = []
143
+ for (let i = 0; i < count; i++) values.push([])
144
+ return values
145
+ }
146
+
147
+ /**
148
+ * @param {SqlPrimitive[][]} values
149
+ * @param {number} rowCount
150
+ * @returns {AsyncBatch}
151
+ */
152
+ function loadedBatch(values, rowCount) {
153
+ return {
154
+ selection: { type: 'all', length: rowCount },
155
+ columns: values.map(function loadedColumn(columnValues) {
156
+ return { type: 'values', values: columnValues, length: rowCount }
157
+ }),
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Avoids a promise boundary when every column is already loaded synchronously.
163
+ *
164
+ * @param {ColumnResult[]} results
165
+ * @returns {ColumnVector[] | Promise<ColumnVector[]>}
166
+ */
167
+ function resolveColumns(results) {
168
+ if (results.some(function isPromise(result) { return result instanceof Promise })) {
169
+ return Promise.all(results)
170
+ }
171
+ /** @type {ColumnVector[]} */
172
+ const vectors = []
173
+ for (const result of results) {
174
+ if (result instanceof Promise) throw new Error('Unexpected asynchronous column result')
175
+ vectors.push(result)
176
+ }
177
+ return vectors
178
+ }
@@ -18,6 +18,19 @@ export function asyncRow(obj, columns) {
18
18
  return { columns, cells, resolved: obj }
19
19
  }
20
20
 
21
+ /**
22
+ * Returns a source's authoritative logical column names.
23
+ *
24
+ * @param {AsyncDataSource} source
25
+ * @returns {string[]}
26
+ */
27
+ export function dataSourceColumns(source) {
28
+ if (source.prepareScan && source.schema) {
29
+ return source.schema.fields.map(function fieldName(field) { return field.name })
30
+ }
31
+ return source.columns ?? []
32
+ }
33
+
21
34
  /**
22
35
  * Creates an async memory-backed data source from an array of plain objects
23
36
  *
@@ -78,6 +91,9 @@ export function memorySource({ data, columns }) {
78
91
  * @returns {AsyncDataSource}
79
92
  */
80
93
  export function cachedDataSource(source) {
94
+ if (source.prepareScan && source.schema) return source
95
+ const { scan } = source
96
+ if (!scan) return source
81
97
  /** @type {WeakMap<object, Map<string, Promise<SqlPrimitive>>>} */
82
98
  const cache = new WeakMap()
83
99
  return {
@@ -85,7 +101,7 @@ export function cachedDataSource(source) {
85
101
  scan(options) {
86
102
  // Does re-run the scan, but cache avoids re-computing expensive async cells
87
103
  // TODO: check cache first to avoid re-scanning when possible
88
- const { rows, appliedWhere, appliedLimitOffset } = source.scan(options)
104
+ const { rows, appliedWhere, appliedLimitOffset } = scan.call(source, options)
89
105
 
90
106
  // Applied where clause changes which rows are returned so can't be cached
91
107
  if (appliedWhere && options.where) {
@@ -1,3 +1,4 @@
1
+ import { dataSourceColumns } from '../backend/dataSource.js'
1
2
  import { derivedAlias } from '../expression/alias.js'
2
3
  import { evaluateExpr } from '../expression/evaluate.js'
3
4
  import { finalizeAccumulator, newAccumulator, updateAccumulator } from './accumulator.js'
@@ -299,7 +300,7 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
299
300
 
300
301
  // COUNT(*) needs a physical column whose filtered chunk lengths can be
301
302
  // counted. Prefer a predicate/projection column, then any table column.
302
- const starColumn = scanNode.hints.columns?.[0] ?? table.columns[0]
303
+ const starColumn = scanNode.hints.columns?.[0] ?? dataSourceColumns(table)[0]
303
304
  if (!starColumn) return
304
305
 
305
306
  // All columns must be simple aggregates on plain identifiers
@@ -0,0 +1,28 @@
1
+ import { batchesToRows } from '../backend/batchAdapters.js'
2
+ import { bindQuerySignal } from './utils.js'
3
+
4
+ /**
5
+ * @import { AsyncBatch, QueryResults } from '../types.js'
6
+ */
7
+
8
+ /**
9
+ * Creates query results backed by batches.
10
+ *
11
+ * @param {object} options
12
+ * @param {string[]} options.columns
13
+ * @param {number} [options.numRows]
14
+ * @param {number} [options.maxRows]
15
+ * @param {() => AsyncIterable<AsyncBatch>} options.batches
16
+ * @param {AbortSignal} [options.signal]
17
+ * @returns {QueryResults}
18
+ */
19
+ export function batchResult({ batches: readBatches, signal, ...metadata }) {
20
+ const results = {
21
+ ...metadata,
22
+ batches: readBatches,
23
+ rows() {
24
+ return batchesToRows(readBatches(), metadata.columns, signal)
25
+ },
26
+ }
27
+ return bindQuerySignal(results, signal)
28
+ }