squirreling 0.15.3 → 0.16.1

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.3",
3
+ "version": "0.16.1",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -40,10 +40,10 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "26.2.0",
43
- "@vitest/coverage-v8": "4.1.10",
43
+ "@vitest/coverage-v8": "4.1.11",
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
- "vitest": "4.1.10"
47
+ "vitest": "4.1.11"
48
48
  }
49
49
  }
@@ -0,0 +1,265 @@
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
+ * Recognizes promises and thenables without relying on realm identity.
22
+ *
23
+ * @template T
24
+ * @param {T | PromiseLike<T>} value
25
+ * @returns {value is PromiseLike<T>}
26
+ */
27
+ export function isPromiseLike(value) {
28
+ if (value === null || typeof value !== 'object' && typeof value !== 'function') return false
29
+ return typeof Reflect.get(value, 'then') === 'function'
30
+ }
31
+
32
+ /**
33
+ * Avoids a promise boundary when every column is already loaded synchronously.
34
+ *
35
+ * @param {ColumnResult[]} results
36
+ * @returns {ColumnVector[] | Promise<ColumnVector[]>}
37
+ */
38
+ export function resolveColumnResults(results) {
39
+ if (results.some(isPromiseLike)) return Promise.all(results)
40
+ /** @type {ColumnVector[]} */
41
+ const vectors = []
42
+ for (const result of results) {
43
+ if (isPromiseLike(result)) throw new Error('Unexpected asynchronous column result')
44
+ vectors.push(result)
45
+ }
46
+ return vectors
47
+ }
48
+
49
+ /**
50
+ * Creates base-aligned ordinal values for the rows in a selection.
51
+ *
52
+ * @param {RowSelection} selection
53
+ * @returns {ColumnVector}
54
+ */
55
+ export function selectionOrdinals(selection) {
56
+ const values = new Uint32Array(selection.length)
57
+ const length = selectedRowCount(selection)
58
+ for (let ordinal = 0; ordinal < length; ordinal++) {
59
+ values[selectionIndexAt(selection, ordinal)] = ordinal
60
+ }
61
+ return { type: 'typed', values, length: selection.length }
62
+ }
63
+
64
+ /**
65
+ * Composes a selection over an already-selected domain with the selection
66
+ * that produced that domain.
67
+ *
68
+ * @param {RowSelection} outer - selection over the original base domain
69
+ * @param {RowSelection} inner - selection over the rows selected by `outer`
70
+ * @returns {RowSelection}
71
+ */
72
+ export function composeSelections(outer, inner) {
73
+ const outerCount = selectedRowCount(outer)
74
+ if (inner.length !== outerCount) {
75
+ throw new Error(`Cannot compose selection of length ${inner.length} over ${outerCount} rows`)
76
+ }
77
+ if (inner.type === 'all') return outer
78
+ if (outer.type === 'all') return { ...inner, length: outer.length }
79
+
80
+ if (outer.type === 'range' && inner.type === 'range') {
81
+ return {
82
+ type: 'range',
83
+ start: outer.start + inner.start,
84
+ end: outer.start + inner.end,
85
+ length: outer.length,
86
+ }
87
+ }
88
+
89
+ const indices = new Uint32Array(selectedRowCount(inner))
90
+ for (let i = 0; i < indices.length; i++) {
91
+ indices[i] = selectionIndexAt(outer, selectionIndexAt(inner, i))
92
+ }
93
+ return { type: 'indices', indices, length: outer.length }
94
+ }
95
+
96
+ /**
97
+ * Reads one logical value from a vector.
98
+ *
99
+ * @param {ColumnVector} vector
100
+ * @param {number} index
101
+ * @returns {SqlPrimitive}
102
+ */
103
+ export function valueAt(vector, index) {
104
+ if (!Number.isInteger(index) || index < 0 || index >= vector.length) {
105
+ throw new RangeError(`Column index ${index} is outside vector length ${vector.length}`)
106
+ }
107
+ if (vector.type === 'values') return vector.values[index]
108
+ if (vector.type === 'typed') {
109
+ if (vector.validity && vector.validity[index] === 0) return null
110
+ return vector.values[index]
111
+ }
112
+ if (vector.type === 'constant') return vector.value
113
+ return valueAt(vector.source, selectionIndexAt(vector.selection, index))
114
+ }
115
+
116
+ /**
117
+ * Creates a zero-copy view of a vector through a row selection.
118
+ *
119
+ * @param {ColumnVector} vector
120
+ * @param {RowSelection} selection
121
+ * @returns {ColumnVector}
122
+ */
123
+ export function selectVector(vector, selection) {
124
+ if (selection.length !== vector.length) {
125
+ throw new Error(`Cannot select ${selection.length} rows from vector length ${vector.length}`)
126
+ }
127
+ if (selection.type === 'all') return vector
128
+ if (vector.type === 'selected') {
129
+ const composed = composeSelections(vector.selection, selection)
130
+ return {
131
+ type: 'selected',
132
+ source: vector.source,
133
+ selection: composed,
134
+ length: selectedRowCount(composed),
135
+ }
136
+ }
137
+ return {
138
+ type: 'selected',
139
+ source: vector,
140
+ selection,
141
+ length: selectedRowCount(selection),
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Resolves one batch column for an effective selection.
147
+ *
148
+ * @param {ReadBatchColumnOptions} options
149
+ * @returns {ColumnResult}
150
+ */
151
+ export function readBatchColumn({ batch, columnIndex, selection = batch.selection, signal }) {
152
+ const column = batch.columns[columnIndex]
153
+ if (!column) throw new RangeError(`Column index ${columnIndex} is outside batch columns`)
154
+ if (selection.length !== batch.selection.length) {
155
+ throw new Error(`Selection length ${selection.length} does not match batch length ${batch.selection.length}`)
156
+ }
157
+ if (!('read' in column)) {
158
+ return selectVector(column, selection)
159
+ }
160
+
161
+ let batchResults = batchCache.get(batch)
162
+ if (!batchResults) {
163
+ batchResults = new Map()
164
+ batchCache.set(batch, batchResults)
165
+ }
166
+ let columnResults = batchResults.get(columnIndex)
167
+ if (!columnResults) {
168
+ columnResults = new Map()
169
+ batchResults.set(columnIndex, columnResults)
170
+ }
171
+ let cache = columnResults.get(selection)
172
+ if (!cache) {
173
+ cache = { pending: new Map() }
174
+ columnResults.set(selection, cache)
175
+ }
176
+ const pending = cache.pending.get(signal)
177
+ if (pending) return pending
178
+ if (cache.resolved) return cache.resolved
179
+
180
+ const result = column.read({
181
+ batch: column.input ?? batch,
182
+ selection,
183
+ signal,
184
+ rowOffset: column.rowOffset,
185
+ rowOrdinals: column.rowOrdinals
186
+ ? selectVector(column.rowOrdinals, selection)
187
+ : undefined,
188
+ })
189
+ const validated = validateColumnResult(result, selectedRowCount(selection))
190
+ if (isPromiseLike(validated)) {
191
+ const settled = Promise.resolve(validated).then(function cacheResolved(vector) {
192
+ cache.pending.delete(signal)
193
+ cache.resolved ??= vector
194
+ return vector
195
+ }, function evictRejected(error) {
196
+ cache.pending.delete(signal)
197
+ throw error
198
+ })
199
+ cache.pending.set(signal, settled)
200
+ return settled
201
+ }
202
+ cache.resolved = validated
203
+ return validated
204
+ }
205
+
206
+ /**
207
+ * Selects rows from a batch without reading or copying its columns.
208
+ *
209
+ * @param {AsyncBatch} batch
210
+ * @param {RowSelection} selection - selection over the batch's current rows
211
+ * @returns {AsyncBatch}
212
+ */
213
+ export function selectBatch(batch, selection) {
214
+ const composed = composeSelections(batch.selection, selection)
215
+ return {
216
+ selection: composed,
217
+ columns: batch.columns,
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Returns a base-domain row index for a logical selected-row index.
223
+ *
224
+ * @param {RowSelection} selection
225
+ * @param {number} index
226
+ * @returns {number}
227
+ */
228
+ function selectionIndexAt(selection, index) {
229
+ const count = selectedRowCount(selection)
230
+ if (!Number.isInteger(index) || index < 0 || index >= count) {
231
+ throw new RangeError(`Selection index ${index} is outside selected length ${count}`)
232
+ }
233
+ if (selection.type === 'all') return index
234
+ if (selection.type === 'range') return selection.start + index
235
+ return selection.indices[index]
236
+ }
237
+
238
+ /**
239
+ * Validates the resolved vector length without forcing synchronous column
240
+ * implementations through a promise.
241
+ *
242
+ * @param {ColumnResult} result
243
+ * @param {number} expectedLength
244
+ * @returns {ColumnResult}
245
+ */
246
+ function validateColumnResult(result, expectedLength) {
247
+ if (isPromiseLike(result)) {
248
+ return Promise.resolve(result).then(function validateResolvedVector(vector) {
249
+ return validateVectorLength(vector, expectedLength)
250
+ })
251
+ }
252
+ return validateVectorLength(result, expectedLength)
253
+ }
254
+
255
+ /**
256
+ * @param {ColumnVector} vector
257
+ * @param {number} expectedLength
258
+ * @returns {ColumnVector}
259
+ */
260
+ function validateVectorLength(vector, expectedLength) {
261
+ if (vector.length !== expectedLength) {
262
+ throw new Error(`Column returned ${vector.length} rows, expected ${expectedLength}`)
263
+ }
264
+ return vector
265
+ }
@@ -0,0 +1,175 @@
1
+ import { readBatchColumn, resolveColumnResults, selectVector, selectedRowCount, valueAt } from './batch.js'
2
+ import { yieldToEventLoop } from '../execute/yield.js'
3
+
4
+ /**
5
+ * @import { AsyncBatch, AsyncCells, AsyncRow, 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
+ /** @type {AsyncRow[]} */
25
+ let bufferedRows = []
26
+
27
+ for await (const row of rows) {
28
+ options?.signal?.throwIfAborted()
29
+ bufferedRows.push(row)
30
+ if (bufferedRows.length === batchRows) {
31
+ yield await materializeRows(bufferedRows, columnNames)
32
+ bufferedRows = []
33
+ }
34
+ }
35
+
36
+ if (bufferedRows.length > 0) yield await materializeRows(bufferedRows, columnNames)
37
+ options?.signal?.throwIfAborted()
38
+ }
39
+
40
+ /**
41
+ * Exposes batches through the legacy lazy-cell row interface. Column reads
42
+ * remain lazy and are memoized once per batch/selection by `readBatchColumn`.
43
+ *
44
+ * @param {AsyncIterable<AsyncBatch>} batches
45
+ * @param {string[]} columns
46
+ * @param {AbortSignal} [signal]
47
+ * @yields {AsyncRow}
48
+ */
49
+ export async function* batchesToRows(batches, columns, signal) {
50
+ for await (const batch of batches) {
51
+ const loadedVectors = batch.columns.map(function loadedVector(column) {
52
+ if ('read' in column) return undefined
53
+ return selectVector(column, batch.selection)
54
+ })
55
+ const rowCount = selectedRowCount(batch.selection)
56
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
57
+ signal?.throwIfAborted()
58
+ /** @type {AsyncCells} */
59
+ const cells = {}
60
+ for (let columnIndex = 0; columnIndex < columns.length; columnIndex++) {
61
+ const currentColumn = columnIndex
62
+ const currentRow = rowIndex
63
+ const loadedVector = loadedVectors[columnIndex]
64
+ if (loadedVector) {
65
+ const value = valueAt(loadedVector, currentRow)
66
+ cells[columns[columnIndex]] = function readLoadedCell() {
67
+ return Promise.resolve(value)
68
+ }
69
+ } else {
70
+ cells[columns[columnIndex]] = async function readCell() {
71
+ const vector = await readBatchColumn({ batch, columnIndex: currentColumn, signal })
72
+ return valueAt(vector, currentRow)
73
+ }
74
+ }
75
+ }
76
+ yield { columns, cells }
77
+ }
78
+ }
79
+ signal?.throwIfAborted()
80
+ }
81
+
82
+ /**
83
+ * Collects batches directly into the existing object-row result shape without
84
+ * constructing compatibility `AsyncRow` values.
85
+ *
86
+ * @param {AsyncIterable<AsyncBatch>} batches
87
+ * @param {string[]} names
88
+ * @param {AbortSignal} [signal]
89
+ * @returns {Promise<Record<string, SqlPrimitive>[]>}
90
+ */
91
+ export async function collectBatches(batches, names, signal) {
92
+ /** @type {Record<string, SqlPrimitive>[]} */
93
+ const rows = []
94
+ for await (const batch of batches) {
95
+ const results = batch.columns.map(function readColumn(_column, columnIndex) {
96
+ return readBatchColumn({ batch, columnIndex, signal })
97
+ })
98
+ const vectors = await resolveColumnResults(results)
99
+ const rowCount = selectedRowCount(batch.selection)
100
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
101
+ if (signal && rowIndex % 4000 === 0) {
102
+ if (rowIndex > 0) await yieldToEventLoop()
103
+ signal.throwIfAborted()
104
+ }
105
+ /** @type {Record<string, SqlPrimitive>} */
106
+ const row = {}
107
+ for (let columnIndex = 0; columnIndex < names.length; columnIndex++) {
108
+ row[names[columnIndex]] = valueAt(vectors[columnIndex], rowIndex)
109
+ }
110
+ rows.push(row)
111
+ }
112
+ }
113
+ signal?.throwIfAborted()
114
+ return rows
115
+ }
116
+
117
+ /**
118
+ * @param {AsyncRow} row
119
+ * @param {string} name
120
+ * @returns {Promise<SqlPrimitive>}
121
+ */
122
+ async function readRowCell(row, name) {
123
+ if (row.resolved && Object.prototype.hasOwnProperty.call(row.resolved, name)) {
124
+ return row.resolved[name]
125
+ }
126
+ const cell = row.cells[name]
127
+ if (!cell) throw new Error(`Row does not contain column "${name}"`)
128
+ return await cell()
129
+ }
130
+
131
+ /**
132
+ * @param {AsyncRow[]} rows
133
+ * @param {string[]} columnNames
134
+ * @returns {Promise<AsyncBatch>}
135
+ */
136
+ async function materializeRows(rows, columnNames) {
137
+ const values = makeValueBuffers(columnNames.length)
138
+ /** @type {Promise<void>[]} */
139
+ const pendingReads = []
140
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
141
+ for (let columnIndex = 0; columnIndex < columnNames.length; columnIndex++) {
142
+ const name = columnNames[columnIndex]
143
+ pendingReads.push(readRowCell(rows[rowIndex], name).then(function storeValue(value) {
144
+ values[columnIndex][rowIndex] = value
145
+ }))
146
+ }
147
+ }
148
+ await Promise.all(pendingReads)
149
+ return loadedBatch(values, rows.length)
150
+ }
151
+
152
+ /**
153
+ * @param {number} count
154
+ * @returns {SqlPrimitive[][]}
155
+ */
156
+ function makeValueBuffers(count) {
157
+ /** @type {SqlPrimitive[][]} */
158
+ const values = []
159
+ for (let i = 0; i < count; i++) values.push([])
160
+ return values
161
+ }
162
+
163
+ /**
164
+ * @param {SqlPrimitive[][]} values
165
+ * @param {number} rowCount
166
+ * @returns {AsyncBatch}
167
+ */
168
+ function loadedBatch(values, rowCount) {
169
+ return {
170
+ selection: { type: 'all', length: rowCount },
171
+ columns: values.map(function loadedColumn(columnValues) {
172
+ return { type: 'values', values: columnValues, length: rowCount }
173
+ }),
174
+ }
175
+ }
@@ -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
+ }