squirreling 0.11.5 → 0.12.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/README.md CHANGED
@@ -34,20 +34,26 @@ const users = [
34
34
  ]
35
35
 
36
36
  // Squirreling return types
37
+ interface QueryResults {
38
+ columns: string[]
39
+ numRows?: number
40
+ maxRows?: number
41
+ rows(): AsyncGenerator<AsyncRow>
42
+ }
37
43
  interface AsyncRow {
38
44
  columns: string[]
39
45
  cells: Record<string, AsyncCell>
40
46
  }
41
47
  type AsyncCell = () => Promise<SqlPrimitive>
42
48
 
43
- // Returns an AsyncIterable of rows with async cell loading
44
- const asyncRows: AsyncIterable<AsyncRow> = executeSql({
49
+ // Returns a QueryResults object with streaming rows
50
+ const { rows } = executeSql({
45
51
  tables: { users },
46
52
  query: 'SELECT * FROM users',
47
53
  })
48
54
 
49
55
  // Process rows as they arrive (streaming)
50
- for await (const { cells } of asyncRows) {
56
+ for await (const { cells } of rows()) {
51
57
  console.log(`User id=${await cells.id()}, name=${await cells.name()}`)
52
58
  }
53
59
  ```
@@ -105,7 +111,7 @@ interface ScanOptions {
105
111
  }
106
112
 
107
113
  interface ScanResults {
108
- rows: AsyncIterable<AsyncRow> // async iterable of rows
114
+ rows(): AsyncIterable<AsyncRow> // async iterable of rows
109
115
  appliedWhere: boolean // WHERE filter applied at scan time?
110
116
  appliedLimitOffset: boolean // LIMIT and OFFSET applied at scan time?
111
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.11.5",
3
+ "version": "0.12.1",
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": "25.5.0",
43
- "@vitest/coverage-v8": "4.1.2",
42
+ "@types/node": "25.5.2",
43
+ "@vitest/coverage-v8": "4.1.3",
44
44
  "eslint": "9.39.2",
45
45
  "eslint-plugin-jsdoc": "62.9.0",
46
46
  "typescript": "6.0.2",
47
- "vitest": "4.1.2"
47
+ "vitest": "4.1.3"
48
48
  }
49
49
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AsyncCells, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
2
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, ScanColumnOptions, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -55,12 +55,12 @@ export function memorySource({ data, columns }) {
55
55
  const start = !where ? offset ?? 0 : 0
56
56
  const end = !where && limit !== undefined ? start + limit : data.length
57
57
  return {
58
- rows: (async function* () {
58
+ async *rows() {
59
59
  for (let i = start; i < end && i < data.length; i++) {
60
60
  if (signal?.aborted) break
61
61
  yield asyncRow(data[i], scanColumns ?? columns)
62
62
  }
63
- })(),
63
+ },
64
64
  appliedWhere: false,
65
65
  appliedLimitOffset: !where,
66
66
  }
@@ -92,9 +92,9 @@ export function cachedDataSource(source) {
92
92
  const indexOffset = appliedLimitOffset && options.offset ? options.offset : 0
93
93
 
94
94
  return {
95
- rows: (async function* () {
95
+ async *rows() {
96
96
  let index = 0
97
- for await (const row of rows) {
97
+ for await (const row of rows()) {
98
98
  if (options.signal?.aborted) break
99
99
  const rowIndex = index + indexOffset
100
100
  /** @type {AsyncCells} */
@@ -115,10 +115,41 @@ export function cachedDataSource(source) {
115
115
  yield { columns: row.columns, cells }
116
116
  index++
117
117
  }
118
- })(),
118
+ },
119
119
  appliedWhere,
120
120
  appliedLimitOffset,
121
121
  }
122
122
  },
123
+ ...source.scanColumn && {
124
+ /**
125
+ * @param {ScanColumnOptions} options
126
+ * @returns {AsyncIterable<ArrayLike<SqlPrimitive>>}
127
+ */
128
+ scanColumn(options) {
129
+ const inner = source.scanColumn(options)
130
+ const indexOffset = options.offset ?? 0
131
+ return (async function* () {
132
+ let chunkStart = 0
133
+ for await (const chunk of inner) {
134
+ if (options.signal?.aborted) break
135
+ /** @type {SqlPrimitive[]} */
136
+ const cached = new Array(chunk.length)
137
+ for (let i = 0; i < chunk.length; i++) {
138
+ const cacheKey = `${chunkStart + i + indexOffset}:${options.column}`
139
+ const existing = cache.get(cacheKey)
140
+ if (existing) {
141
+ cached[i] = await existing
142
+ } else {
143
+ const value = chunk[i]
144
+ cache.set(cacheKey, Promise.resolve(value))
145
+ cached[i] = value
146
+ }
147
+ }
148
+ yield cached
149
+ chunkStart += chunk.length
150
+ }
151
+ })()
152
+ },
153
+ },
123
154
  }
124
155
  }
@@ -1,10 +1,10 @@
1
1
  import { derivedAlias } from '../expression/alias.js'
2
2
  import { evaluateExpr } from '../expression/evaluate.js'
3
- import { executePlan } from './execute.js'
3
+ import { executePlan, selectColumnNames } from './execute.js'
4
4
  import { keyify } from './utils.js'
5
5
 
6
6
  /**
7
- * @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, SelectColumn, SqlPrimitive } from '../types.js'
7
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, QueryResults, SelectColumn, SqlPrimitive } from '../types.js'
8
8
  * @import { HashAggregateNode, ScalarAggregateNode } from '../plan/types.js'
9
9
  */
10
10
 
@@ -55,52 +55,59 @@ function projectAggregateColumns(selectColumns, group, context) {
55
55
  *
56
56
  * @param {HashAggregateNode} plan
57
57
  * @param {ExecuteContext} context
58
- * @yields {AsyncRow}
58
+ * @returns {QueryResults}
59
59
  */
60
- export async function* executeHashAggregate(plan, context) {
61
- // Collect all rows
62
- /** @type {AsyncRow[]} */
63
- const allRows = []
64
- for await (const row of executePlan({ plan: plan.child, context })) {
65
- if (context.signal?.aborted) return
66
- allRows.push(row)
67
- }
60
+ export function executeHashAggregate(plan, context) {
61
+ const child = executePlan({ plan: plan.child, context })
62
+ return {
63
+ columns: selectColumnNames(plan.columns, child.columns),
64
+ maxRows: child.maxRows,
65
+ async *rows () {
66
+ // Collect all rows
67
+ /** @type {AsyncRow[]} */
68
+ const allRows = []
69
+ for await (const row of child.rows()) {
70
+ if (context.signal?.aborted) return
71
+ allRows.push(row)
72
+ }
68
73
 
69
- // Group rows by GROUP BY keys
70
- /** @type {Map<any, AsyncRow[]>} */
71
- const groups = new Map()
74
+ // Group rows by GROUP BY keys
75
+ /** @type {Map<any, AsyncRow[]>} */
76
+ const groups = new Map()
72
77
 
73
- for (const row of allRows) {
74
- const key = keyify(...await Promise.all(plan.groupBy.map(expr => evaluateExpr({ node: expr, row, context }))))
75
- let group = groups.get(key)
76
- if (!group) {
77
- group = []
78
- groups.set(key, group)
79
- }
80
- group.push(row)
81
- }
78
+ for (const row of allRows) {
79
+ const key = keyify(...await Promise.all(plan.groupBy.map(expr => evaluateExpr({ node: expr, row, context }))))
80
+ let group = groups.get(key)
81
+ if (!group) {
82
+ group = []
83
+ groups.set(key, group)
84
+ }
85
+ group.push(row)
86
+ }
82
87
 
83
- // Yield one row per group
84
- for (const group of groups.values()) {
85
- const asyncRow = projectAggregateColumns(plan.columns, group, context)
88
+ // Yield one row per group
89
+ for (const group of groups.values()) {
90
+ const asyncRow = projectAggregateColumns(plan.columns, group, context)
86
91
 
87
- // Apply HAVING filter
88
- if (plan.having) {
89
- /** @type {AsyncRow} */
90
- const havingRow = {
91
- columns: [...group[0].columns, ...asyncRow.columns],
92
- cells: { ...group[0].cells, ...asyncRow.cells },
93
- }
94
- const passes = await evaluateExpr({
95
- node: plan.having,
96
- row: havingRow,
97
- rows: group,
98
- context,
99
- })
100
- if (!passes) continue
101
- }
92
+ // Apply HAVING filter
93
+ if (plan.having) {
94
+ /** @type {AsyncRow} */
95
+ const havingRow = {
96
+ columns: [...group[0].columns, ...asyncRow.columns],
97
+ cells: { ...group[0].cells, ...asyncRow.cells },
98
+ }
99
+ const passes = await evaluateExpr({
100
+ node: plan.having,
101
+ row: havingRow,
102
+ rows: group,
103
+ context,
104
+ })
105
+ if (!passes) continue
106
+ }
102
107
 
103
- yield asyncRow
108
+ yield asyncRow
109
+ }
110
+ },
104
111
  }
105
112
  }
106
113
 
@@ -109,43 +116,57 @@ export async function* executeHashAggregate(plan, context) {
109
116
  *
110
117
  * @param {ScalarAggregateNode} plan
111
118
  * @param {ExecuteContext} context
112
- * @yields {AsyncRow}
119
+ * @returns {QueryResults}
113
120
  */
114
- export async function* executeScalarAggregate(plan, context) {
121
+ export function executeScalarAggregate(plan, context) {
115
122
  // Fast path: use scanColumn when available
123
+ const scalarColumns = selectColumnNames(plan.columns, [])
116
124
  const fast = tryColumnScanAggregate(plan, context)
117
125
  if (fast) {
118
- yield* fast
119
- return
126
+ return {
127
+ columns: scalarColumns,
128
+ numRows: 1,
129
+ maxRows: 1,
130
+ rows: fast,
131
+ }
120
132
  }
121
133
 
122
- // Collect all rows into single group
123
- /** @type {AsyncRow[]} */
124
- const group = []
125
- for await (const row of executePlan({ plan: plan.child, context })) {
126
- if (context.signal?.aborted) return
127
- group.push(row)
128
- }
134
+ const child = executePlan({ plan: plan.child, context })
135
+ return {
136
+ columns: selectColumnNames(plan.columns, child.columns),
137
+ numRows: plan.having ? undefined : 1,
138
+ maxRows: 1,
139
+ async *rows () {
140
+ // Collect all rows into single group
141
+ /** @type {AsyncRow[]} */
142
+ const group = []
143
+ for await (const row of child.rows()) {
144
+ if (context.signal?.aborted) return
145
+ group.push(row)
146
+ }
129
147
 
130
- const asyncRow = projectAggregateColumns(plan.columns, group, context)
148
+ const asyncRow = projectAggregateColumns(plan.columns, group, context)
131
149
 
132
- // Apply HAVING filter
133
- if (plan.having) {
134
- /** @type {AsyncRow} */
135
- const havingRow = {
136
- columns: [...group[0].columns, ...asyncRow.columns],
137
- cells: { ...group[0].cells, ...asyncRow.cells },
138
- }
139
- const passes = await evaluateExpr({
140
- node: plan.having,
141
- row: havingRow,
142
- rows: group,
143
- context,
144
- })
145
- if (!passes) return
146
- }
150
+ // Apply HAVING filter
151
+ if (plan.having) {
152
+ const baseRow = group[0] ?? { columns: [], cells: {} }
153
+ /** @type {AsyncRow} */
154
+ const havingRow = {
155
+ columns: [...baseRow.columns, ...asyncRow.columns],
156
+ cells: { ...baseRow.cells, ...asyncRow.cells },
157
+ }
158
+ const passes = await evaluateExpr({
159
+ node: plan.having,
160
+ row: havingRow,
161
+ rows: group,
162
+ context,
163
+ })
164
+ if (!passes) return
165
+ }
147
166
 
148
- yield asyncRow
167
+ yield asyncRow
168
+ },
169
+ }
149
170
  }
150
171
 
151
172
  /**
@@ -163,7 +184,7 @@ export async function* executeScalarAggregate(plan, context) {
163
184
  *
164
185
  * @param {ScalarAggregateNode} plan
165
186
  * @param {ExecuteContext} context
166
- * @returns {AsyncGenerator<AsyncRow> | undefined}
187
+ * @returns {(() => AsyncGenerator<AsyncRow>) | undefined}
167
188
  */
168
189
  function tryColumnScanAggregate(plan, { tables, signal }) {
169
190
  // No HAVING support in fast path
@@ -188,7 +209,7 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
188
209
  specs.push(spec)
189
210
  }
190
211
 
191
- return (async function* () {
212
+ return async function* () {
192
213
  /** @type {string[]} */
193
214
  const columns = []
194
215
  /** @type {AsyncCells} */
@@ -200,7 +221,7 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
200
221
  }
201
222
 
202
223
  yield { columns, cells }
203
- })()
224
+ }
204
225
  }
205
226
 
206
227
  /**