squirreling 0.2.6 → 0.3.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/README.md CHANGED
@@ -7,30 +7,53 @@
7
7
  [![minzipped](https://img.shields.io/bundlephobia/minzip/squirreling)](https://www.npmjs.com/package/squirreling)
8
8
  [![workflow status](https://github.com/hyparam/squirreling/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/squirreling/actions)
9
9
  [![mit license](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT)
10
- ![coverage](https://img.shields.io/badge/Coverage-89-darkred)
10
+ ![coverage](https://img.shields.io/badge/Coverage-90-darkred)
11
11
  [![dependencies](https://img.shields.io/badge/Dependencies-0-blueviolet)](https://www.npmjs.com/package/squirreling?activeTab=dependencies)
12
12
 
13
- Squirreling is a lightweight SQL engine for JavaScript applications, designed to provide efficient and easy-to-use database functionalities in the browser.
13
+ Squirreling is a streaming async SQL engine for JavaScript. It is designed to provide efficient streaming of results from pluggable backend for highly efficient retrieval of data for browser applications.
14
14
 
15
15
  ## Features
16
16
 
17
17
  - Lightweight and fast
18
- - Easy to integrate with JavaScript applications
18
+ - Easy to integrate with frontend applications
19
+ - Lets you move query execution closer to your users
19
20
  - Supports standard SQL queries
20
- - In-memory database for quick data access
21
- - Robust error handling and validation
21
+ - Async streaming for large datasets
22
+ - Constant memory usage for simple queries with LIMIT
23
+ - Robust error handling and validation designed for LLM tool use
24
+ - In-memory data option for simple use cases
22
25
 
23
26
  ## Usage
24
27
 
28
+ Squirreling returns an async generator, allowing you to process rows one at a time without loading everything into memory.
29
+
25
30
  ```javascript
26
31
  import { executeSql } from 'squirreling'
27
32
 
28
- const source = [
29
- { id: 1, name: 'Alice' },
30
- { id: 2, name: 'Bob' },
33
+ // In-memory table
34
+ const users = [
35
+ { id: 1, name: 'Alice', active: true },
36
+ { id: 2, name: 'Bob', active: false },
37
+ { id: 3, name: 'Charlie', active: true },
38
+ // ...more rows
31
39
  ]
32
40
 
33
- const result = executeSql({ source, query: 'SELECT UPPER(name) AS name_upper FROM users' })
34
- console.log(result)
35
- // Output: [ { name_upper: 'ALICE' }, { name_upper: 'BOB' } ]
41
+ // Process rows as they arrive (streaming)
42
+ for await (const user of executeSql({
43
+ tables: { users },
44
+ query: 'SELECT * FROM users WHERE active = TRUE LIMIT 100',
45
+ })) {
46
+ console.log(user.name)
47
+ }
48
+ ```
49
+
50
+ There is an exported helper function `collect` to gather all rows into an array if needed:
51
+
52
+ ```javascript
53
+ import { collect } from 'squirreling'
54
+ const allUsers = await collect(executeSql({
55
+ tables: { users },
56
+ query: 'SELECT * FROM users',
57
+ }))
58
+ console.log(allUsers)
36
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "24.10.1",
41
- "@vitest/coverage-v8": "4.0.13",
41
+ "@vitest/coverage-v8": "4.0.14",
42
42
  "eslint": "9.39.1",
43
43
  "eslint-plugin-jsdoc": "61.4.1",
44
44
  "typescript": "5.9.3",
45
- "vitest": "4.0.13"
45
+ "vitest": "4.0.14"
46
46
  }
47
47
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { DataSource, RowSource } from '../types.js'
2
+ * @import { AsyncDataSource, RowSource } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -20,18 +20,17 @@ export function createRowAccessor(obj) {
20
20
  }
21
21
 
22
22
  /**
23
- * Creates a memory-backed data source from an array of plain objects
23
+ * Creates an async memory-backed data source from an array of plain objects
24
24
  *
25
25
  * @param {Record<string, any>[]} data - array of plain objects
26
- * @returns {DataSource} a data source interface
26
+ * @returns {AsyncDataSource} an async data source interface
27
27
  */
28
- export function createMemorySource(data) {
28
+ export function createAsyncMemorySource(data) {
29
29
  return {
30
- getNumRows() {
31
- return data.length
32
- },
33
- getRow(index) {
34
- return createRowAccessor(data[index])
30
+ async *getRows() {
31
+ for (const item of data) {
32
+ yield createRowAccessor(item)
33
+ }
35
34
  },
36
35
  }
37
36
  }
@@ -6,16 +6,16 @@ import { evaluateExpr } from './expression.js'
6
6
  * @import { AggregateColumn, ExprNode, RowSource } from '../types.js'
7
7
  * @param {AggregateColumn} col - aggregate column definition
8
8
  * @param {RowSource[]} rows - rows to aggregate
9
- * @returns {number | null} aggregated result
9
+ * @returns {Promise<number | null>} aggregated result
10
10
  */
11
- export function evaluateAggregate(col, rows) {
11
+ export async function evaluateAggregate(col, rows) {
12
12
  const { arg, func } = col
13
13
 
14
14
  if (func === 'COUNT') {
15
15
  if (arg.kind === 'star') return rows.length
16
16
  let count = 0
17
17
  for (let i = 0; i < rows.length; i += 1) {
18
- const v = evaluateExpr(arg.expr, rows[i])
18
+ const v = await evaluateExpr({ node: arg.expr, row: rows[i] })
19
19
  if (v !== null && v !== undefined) {
20
20
  count += 1
21
21
  }
@@ -35,7 +35,7 @@ export function evaluateAggregate(col, rows) {
35
35
  let max = null
36
36
 
37
37
  for (let i = 0; i < rows.length; i += 1) {
38
- const raw = evaluateExpr(arg.expr, rows[i])
38
+ const raw = await evaluateExpr({ node: arg.expr, row: rows[i] })
39
39
  if (raw == null) continue
40
40
  const num = Number(raw)
41
41
  if (!Number.isFinite(num)) continue
@@ -1,23 +1,70 @@
1
- import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
2
1
  import { evaluateExpr } from './expression.js'
3
- import { evaluateHavingExpr } from './having.js'
4
2
  import { parseSql } from '../parse/parse.js'
5
- import { createMemorySource, createRowAccessor } from '../backend/memory.js'
3
+ import { createAsyncMemorySource, createRowAccessor } from '../backend/memory.js'
4
+ import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
5
+ import { evaluateHavingExpr } from './having.js'
6
+ import { collect } from './utils.js'
6
7
 
7
8
  /**
8
- * @import { DataSource, ExecuteSqlOptions, ExprNode, OrderByItem, RowSource, SelectStatement, SqlPrimitive } from '../types.js'
9
+ * @import { AsyncDataSource, ExecuteSqlOptions, ExprNode, OrderByItem, RowSource, SelectStatement, SqlPrimitive } from '../types.js'
9
10
  */
10
11
 
11
12
  /**
12
- * Executes a SQL SELECT query against a data source
13
+ * Executes a SQL SELECT query against named data sources
13
14
  *
14
15
  * @param {ExecuteSqlOptions} options - the execution options
15
- * @returns {Record<string, any>[]} the result rows matching the query
16
+ * @returns {AsyncGenerator<Record<string, any>>} async generator yielding result rows
16
17
  */
17
- export function executeSql({ source, query }) {
18
+ export async function* executeSql({ tables, query }) {
18
19
  const select = parseSql(query)
19
- const dataSource = Array.isArray(source) ? createMemorySource(source) : source
20
- return evaluateSelectAst(select, dataSource)
20
+
21
+ // Check for unsupported operations
22
+ if (select.joins.length) {
23
+ throw new Error('JOIN is not supported')
24
+ }
25
+ if (!select.from) {
26
+ throw new Error('FROM clause is required')
27
+ }
28
+
29
+ // Normalize tables: convert arrays to AsyncDataSource
30
+ /** @type {Record<string, AsyncDataSource>} */
31
+ const normalizedTables = {}
32
+ for (const [name, source] of Object.entries(tables)) {
33
+ if (Array.isArray(source)) {
34
+ normalizedTables[name] = createAsyncMemorySource(source)
35
+ } else {
36
+ normalizedTables[name] = source
37
+ }
38
+ }
39
+
40
+ yield* executeSelect(select, normalizedTables)
41
+ }
42
+
43
+ /**
44
+ * Executes a SELECT query against the provided tables
45
+ *
46
+ * @param {SelectStatement} select
47
+ * @param {Record<string, AsyncDataSource>} tables
48
+ * @returns {AsyncGenerator<Record<string, any>>} async generator yielding result rows
49
+ */
50
+ export async function* executeSelect(select, tables) {
51
+ /** @type {AsyncDataSource} */
52
+ let dataSource
53
+
54
+ if (typeof select.from === 'string') {
55
+ const table = tables[select.from]
56
+ if (table === undefined) {
57
+ throw new Error(`Table "${select.from}" not found`)
58
+ }
59
+
60
+ dataSource = table
61
+ } else {
62
+ // Nested subquery - recursively resolve
63
+ const derivedData = await collect(executeSelect(select.from.query, tables))
64
+ dataSource = createAsyncMemorySource(derivedData)
65
+ }
66
+
67
+ yield* evaluateSelectAst(select, dataSource, tables)
21
68
  }
22
69
 
23
70
  /**
@@ -112,32 +159,108 @@ function applyDistinct(rows, distinct) {
112
159
  return result
113
160
  }
114
161
 
162
+ /**
163
+ * Applies ORDER BY sorting to RowSource array (before projection)
164
+ *
165
+ * @param {RowSource[]} rows - the input row sources
166
+ * @param {OrderByItem[]} orderBy - the sort specifications
167
+ * @param {Record<string, AsyncDataSource>} tables
168
+ * @returns {Promise<RowSource[]>} the sorted row sources
169
+ */
170
+ async function sortRowSources(rows, orderBy, tables) {
171
+ if (!orderBy?.length) return rows
172
+
173
+ // Pre-evaluate ORDER BY expressions for all rows
174
+ /** @type {SqlPrimitive[][]} */
175
+ const evaluatedValues = []
176
+ for (const row of rows) {
177
+ /** @type {SqlPrimitive[]} */
178
+ const rowValues = []
179
+ for (const term of orderBy) {
180
+ const value = await evaluateExpr({ node: term.expr, row, tables })
181
+ rowValues.push(value)
182
+ }
183
+ evaluatedValues.push(rowValues)
184
+ }
185
+
186
+ // Create index array and sort it
187
+ const indices = rows.map((_, i) => i)
188
+ indices.sort((aIdx, bIdx) => {
189
+ for (let termIdx = 0; termIdx < orderBy.length; termIdx++) {
190
+ const term = orderBy[termIdx]
191
+ const dir = term.direction
192
+ const av = evaluatedValues[aIdx][termIdx]
193
+ const bv = evaluatedValues[bIdx][termIdx]
194
+
195
+ // Handle NULLS FIRST / NULLS LAST
196
+ const aIsNull = av == null
197
+ const bIsNull = bv == null
198
+
199
+ if (aIsNull || bIsNull) {
200
+ if (aIsNull && bIsNull) continue
201
+
202
+ const nullsFirst = term.nulls === 'LAST' ? false : true
203
+
204
+ if (aIsNull) {
205
+ return nullsFirst ? -1 : 1
206
+ } else {
207
+ return nullsFirst ? 1 : -1
208
+ }
209
+ }
210
+
211
+ const cmp = compareValues(av, bv)
212
+ if (cmp !== 0) {
213
+ return dir === 'DESC' ? -cmp : cmp
214
+ }
215
+ }
216
+ return 0
217
+ })
218
+
219
+ // Return sorted rows
220
+ return indices.map(i => rows[i])
221
+ }
222
+
115
223
  /**
116
224
  * Applies ORDER BY sorting to rows
117
225
  *
118
226
  * @param {Record<string, any>[]} rows - the input rows
119
227
  * @param {OrderByItem[]} orderBy - the sort specifications
120
- * @returns {Record<string, any>[]} the sorted rows
228
+ * @param {Record<string, AsyncDataSource>} tables
229
+ * @returns {Promise<Record<string, any>[]>} the sorted rows
121
230
  */
122
- function applyOrderBy(rows, orderBy) {
231
+ async function applyOrderBy(rows, orderBy, tables) {
123
232
  if (!orderBy?.length) return rows
124
233
 
125
- const sorted = rows.slice()
126
- sorted.sort((a, b) => {
234
+ // Pre-evaluate ORDER BY expressions for all rows
235
+ /** @type {SqlPrimitive[][]} */
236
+ const evaluatedValues = []
237
+ for (const row of rows) {
238
+ /** @type {SqlPrimitive[]} */
239
+ const rowValues = []
127
240
  for (const term of orderBy) {
241
+ const value = await evaluateExpr({ node: term.expr, row: createRowAccessor(row), tables })
242
+ rowValues.push(value)
243
+ }
244
+ evaluatedValues.push(rowValues)
245
+ }
246
+
247
+ // Create index array and sort it
248
+ const indices = rows.map((_, i) => i)
249
+ indices.sort((aIdx, bIdx) => {
250
+ for (let termIdx = 0; termIdx < orderBy.length; termIdx++) {
251
+ const term = orderBy[termIdx]
128
252
  const dir = term.direction
129
- const av = evaluateExpr(term.expr, createRowAccessor(a))
130
- const bv = evaluateExpr(term.expr, createRowAccessor(b))
253
+ const av = evaluatedValues[aIdx][termIdx]
254
+ const bv = evaluatedValues[bIdx][termIdx]
131
255
 
132
256
  // Handle NULLS FIRST / NULLS LAST
133
257
  const aIsNull = av == null
134
258
  const bIsNull = bv == null
135
259
 
136
260
  if (aIsNull || bIsNull) {
137
- if (aIsNull && bIsNull) continue // both null, try next sort term
261
+ if (aIsNull && bIsNull) continue
138
262
 
139
- // Determine null ordering
140
- const nullsFirst = term.nulls === 'LAST' ? false : true // default is NULLS FIRST
263
+ const nullsFirst = term.nulls === 'LAST' ? false : true
141
264
 
142
265
  if (aIsNull) {
143
266
  return nullsFirst ? -1 : 1
@@ -154,45 +277,134 @@ function applyOrderBy(rows, orderBy) {
154
277
  return 0
155
278
  })
156
279
 
157
- return sorted
280
+ // Return sorted rows
281
+ return indices.map(i => rows[i])
158
282
  }
159
283
 
160
284
  /**
161
- * Evaluates a parsed SELECT AST against data rows
285
+ * Evaluates a select with a resolved FROM data source
162
286
  *
163
287
  * @param {SelectStatement} select - the parsed SQL AST
164
- * @param {DataSource} dataSource - the data source
165
- * @returns {Record<string, any>[]} the filtered, projected, and sorted result rows
288
+ * @param {AsyncDataSource} dataSource - the async data source
289
+ * @param {Record<string, AsyncDataSource>} tables
290
+ * @returns {AsyncGenerator<Record<string, any>>} async generator yielding result rows
166
291
  */
167
- function evaluateSelectAst(select, dataSource) {
168
- // Check for unsupported JOIN operations
169
- if (select.joins.length) {
170
- throw new Error('JOIN is not supported')
171
- }
292
+ async function* evaluateSelectAst(select, dataSource, tables) {
293
+ // SQL priority: from, where, group by, having, select, order by, offset, limit
294
+
295
+ const hasAggregate = select.columns.some(col => col.kind === 'aggregate')
296
+ const useGrouping = hasAggregate || select.groupBy?.length > 0
172
297
 
173
- // Check for unsupported subquery in FROM clause
174
- if (typeof select.from !== 'string') {
175
- throw new Error('Subquery in FROM clause is not supported')
298
+ // Determine if we need to buffer (collect all rows first)
299
+ const needsBuffering =
300
+ select.orderBy.length > 0 ||
301
+ select.distinct ||
302
+ useGrouping
303
+
304
+ if (needsBuffering) {
305
+ // BUFFERING PATH: Collect all rows, process, then yield
306
+ yield* evaluateBuffered(select, dataSource, tables, hasAggregate, useGrouping)
307
+ } else {
308
+ // STREAMING PATH: Yield rows one by one
309
+ yield* evaluateStreaming(select, dataSource, tables)
176
310
  }
311
+ }
177
312
 
178
- // SQL priority: from, where, group by, having, select, order by, offset, limit
313
+ /**
314
+ * Streaming evaluation for simple queries (no ORDER BY, DISTINCT, or GROUP BY)
315
+ *
316
+ * @param {SelectStatement} select
317
+ * @param {AsyncDataSource} dataSource
318
+ * @param {Record<string, AsyncDataSource>} tables
319
+ * @returns {AsyncGenerator<Record<string, any>>}
320
+ */
321
+ async function* evaluateStreaming(select, dataSource, tables) {
322
+ let rowsYielded = 0
323
+ let rowsSkipped = 0
324
+ const offset = select.offset ?? 0
325
+ const limit = select.limit ?? Infinity
326
+
327
+ for await (const row of dataSource.getRows()) {
328
+ // WHERE filter
329
+ if (select.where) {
330
+ const passes = await evaluateExpr({ node: select.where, row, tables })
331
+
332
+ if (!passes) {
333
+ continue
334
+ }
335
+ }
179
336
 
180
- // WHERE clause filtering
337
+ // OFFSET handling
338
+ if (rowsSkipped < offset) {
339
+ rowsSkipped++
340
+ continue
341
+ }
342
+
343
+ // LIMIT handling
344
+ if (rowsYielded >= limit) {
345
+ break
346
+ }
347
+
348
+ // SELECT projection
349
+ /** @type {Record<string, any>} */
350
+ const outRow = {}
351
+ for (const col of select.columns) {
352
+ if (col.kind === 'star') {
353
+ const keys = row.getKeys()
354
+ for (const key of keys) {
355
+ outRow[key] = row.getCell(key)
356
+ }
357
+ } else if (col.kind === 'derived') {
358
+ const alias = col.alias ?? defaultDerivedAlias(col.expr)
359
+ outRow[alias] = await evaluateExpr({ node: col.expr, row, tables })
360
+ } else if (col.kind === 'aggregate') {
361
+ throw new Error(
362
+ 'Aggregate functions require GROUP BY or will act on the whole dataset; add GROUP BY or remove aggregates'
363
+ )
364
+ }
365
+ }
366
+
367
+ yield outRow
368
+ rowsYielded++
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Buffered evaluation for complex queries (with ORDER BY, DISTINCT, or GROUP BY)
374
+ *
375
+ * @param {SelectStatement} select
376
+ * @param {AsyncDataSource} dataSource
377
+ * @param {Record<string, AsyncDataSource>} tables
378
+ * @param {boolean} hasAggregate
379
+ * @param {boolean} useGrouping
380
+ * @returns {AsyncGenerator<Record<string, any>>}
381
+ */
382
+ async function* evaluateBuffered(select, dataSource, tables, hasAggregate, useGrouping) {
383
+ // Step 1: Collect all rows from data source
181
384
  /** @type {RowSource[]} */
182
385
  const working = []
183
- const length = dataSource.getNumRows()
184
- for (let i = 0; i < length; i++) {
185
- const row = dataSource.getRow(i)
186
- if (!select.where || evaluateExpr(select.where, row)) {
187
- working.push(row)
188
- }
386
+ for await (const row of dataSource.getRows()) {
387
+ working.push(row)
189
388
  }
190
389
 
191
- const hasAggregate = select.columns.some(col => col.kind === 'aggregate')
192
- const useGrouping = hasAggregate || select.groupBy?.length > 0
390
+ // Step 2: WHERE clause filtering
391
+ /** @type {RowSource[]} */
392
+ const filtered = []
393
+
394
+ for (const row of working) {
395
+ if (select.where) {
396
+ const passes = await evaluateExpr({ node: select.where, row, tables })
193
397
 
398
+ if (!passes) {
399
+ continue
400
+ }
401
+ }
402
+ filtered.push(row)
403
+ }
404
+
405
+ // Step 3: Projection (grouping vs non-grouping)
194
406
  /** @type {Record<string, any>[]} */
195
- const projected = []
407
+ let projected = []
196
408
 
197
409
  if (useGrouping) {
198
410
  // Grouping due to GROUP BY or aggregate functions
@@ -202,11 +414,11 @@ function evaluateSelectAst(select, dataSource) {
202
414
  if (select.groupBy?.length) {
203
415
  /** @type {Map<string, RowSource[]>} */
204
416
  const map = new Map()
205
- for (const row of working) {
417
+ for (const row of filtered) {
206
418
  /** @type {string[]} */
207
419
  const keyParts = []
208
420
  for (const expr of select.groupBy) {
209
- const v = evaluateExpr(expr, row)
421
+ const v = await evaluateExpr({ node: expr, row, tables })
210
422
  keyParts.push(JSON.stringify(v))
211
423
  }
212
424
  const key = keyParts.join('|')
@@ -219,7 +431,7 @@ function evaluateSelectAst(select, dataSource) {
219
431
  group.push(row)
220
432
  }
221
433
  } else {
222
- groups.push(working)
434
+ groups.push(filtered)
223
435
  }
224
436
 
225
437
  const hasStar = select.columns.some(col => col.kind === 'star')
@@ -244,14 +456,18 @@ function evaluateSelectAst(select, dataSource) {
244
456
 
245
457
  if (col.kind === 'derived') {
246
458
  const alias = col.alias ?? defaultDerivedAlias(col.expr)
247
- const value = group.length > 0 ? evaluateExpr(col.expr, group[0]) : undefined
248
- resultRow[alias] = value
459
+ if (group.length > 0) {
460
+ const value = await evaluateExpr({ node: col.expr, row: group[0], tables })
461
+ resultRow[alias] = value
462
+ } else {
463
+ resultRow[alias] = undefined
464
+ }
249
465
  continue
250
466
  }
251
467
 
252
468
  if (col.kind === 'aggregate') {
253
469
  const alias = col.alias ?? defaultAggregateAlias(col)
254
- const value = evaluateAggregate(col, group)
470
+ const value = await evaluateAggregate(col, group)
255
471
  resultRow[alias] = value
256
472
  continue
257
473
  }
@@ -259,9 +475,7 @@ function evaluateSelectAst(select, dataSource) {
259
475
 
260
476
  // Apply HAVING filter before adding to projected results
261
477
  if (select.having) {
262
- // For HAVING, we need to evaluate aggregates in the context of the group
263
- // Create a special row context that includes both the group data and aggregate values
264
- if (!evaluateHavingExpr(select.having, resultRow, group)) {
478
+ if (!await evaluateHavingExpr(select.having, resultRow, group, tables)) {
265
479
  continue
266
480
  }
267
481
  }
@@ -270,7 +484,10 @@ function evaluateSelectAst(select, dataSource) {
270
484
  }
271
485
  } else {
272
486
  // No grouping, simple projection
273
- for (const row of working) {
487
+ // Sort before projection so ORDER BY can access columns not in SELECT
488
+ const sorted = await sortRowSources(filtered, select.orderBy, tables)
489
+
490
+ for (const row of sorted) {
274
491
  /** @type {Record<string, any>} */
275
492
  const outRow = {}
276
493
  for (const col of select.columns) {
@@ -281,29 +498,26 @@ function evaluateSelectAst(select, dataSource) {
281
498
  }
282
499
  } else if (col.kind === 'derived') {
283
500
  const alias = col.alias ?? defaultDerivedAlias(col.expr)
284
- const value = evaluateExpr(col.expr, row)
501
+ const value = await evaluateExpr({ node: col.expr, row, tables })
285
502
  outRow[alias] = value
286
- } else if (col.kind === 'aggregate') {
287
- throw new Error(
288
- 'Aggregate functions require GROUP BY or will act on the whole dataset; add GROUP BY or remove aggregates'
289
- )
290
503
  }
291
504
  }
292
505
  projected.push(outRow)
293
506
  }
294
507
  }
295
508
 
296
- let result = projected
509
+ // Step 4: DISTINCT
510
+ projected = applyDistinct(projected, select.distinct)
297
511
 
298
- result = applyDistinct(result, select.distinct)
299
- result = applyOrderBy(result, select.orderBy)
512
+ // Step 5: ORDER BY (final sort for grouped queries)
513
+ projected = await applyOrderBy(projected, select.orderBy, tables)
300
514
 
301
- if (typeof select.offset === 'number' && select.offset > 0) {
302
- result = result.slice(select.offset)
303
- }
304
- if (typeof select.limit === 'number') {
305
- result = result.slice(0, select.limit)
306
- }
515
+ // Step 6: OFFSET and LIMIT
516
+ const start = select.offset ?? 0
517
+ const end = select.limit ? start + select.limit : projected.length
307
518
 
308
- return result
519
+ // Step 7: Yield results
520
+ for (let i = start; i < end && i < projected.length; i++) {
521
+ yield projected[i]
522
+ }
309
523
  }
@@ -1,13 +1,20 @@
1
+ import { executeSelect } from './execute.js'
2
+ import { collect } from './utils.js'
1
3
 
2
4
  /**
3
- * Evaluates an expression node against a row of data
5
+ * @import { ExprNode, RowSource, SqlPrimitive, AsyncDataSource } from '../types.js'
6
+ */
7
+
8
+ /**
9
+ * Evaluates an expression node against a row of data (async version)
4
10
  *
5
- * @import { ExprNode, RowSource, SqlPrimitive } from '../types.js'
6
- * @param {ExprNode} node - The expression node to evaluate
7
- * @param {RowSource} row - The data row to evaluate against
8
- * @returns {SqlPrimitive} The result of the evaluation
11
+ * @param {Object} params
12
+ * @param {ExprNode} params.node - The expression node to evaluate
13
+ * @param {RowSource} params.row - The data row to evaluate against
14
+ * @param {Record<string, AsyncDataSource>} [params.tables]
15
+ * @returns {Promise<SqlPrimitive>} The result of the evaluation
9
16
  */
10
- export function evaluateExpr(node, row) {
17
+ export async function evaluateExpr({ node, row, tables }) {
11
18
  if (node.type === 'literal') {
12
19
  return node.value
13
20
  }
@@ -19,16 +26,16 @@ export function evaluateExpr(node, row) {
19
26
  // Unary operators
20
27
  if (node.type === 'unary') {
21
28
  if (node.op === 'NOT') {
22
- return !evaluateExpr(node.argument, row)
29
+ return !await evaluateExpr({ node: node.argument, row, tables })
23
30
  }
24
31
  if (node.op === 'IS NULL') {
25
- return evaluateExpr(node.argument, row) == null
32
+ return await evaluateExpr({ node: node.argument, row, tables }) == null
26
33
  }
27
34
  if (node.op === 'IS NOT NULL') {
28
- return evaluateExpr(node.argument, row) != null
35
+ return await evaluateExpr({ node: node.argument, row, tables }) != null
29
36
  }
30
37
  if (node.op === '-') {
31
- const val = evaluateExpr(node.argument, row)
38
+ const val = await evaluateExpr({ node: node.argument, row, tables })
32
39
  if (val == null) return null
33
40
  return -Number(val)
34
41
  }
@@ -37,19 +44,19 @@ export function evaluateExpr(node, row) {
37
44
  // Binary operators
38
45
  if (node.type === 'binary') {
39
46
  if (node.op === 'AND') {
40
- const leftVal = evaluateExpr(node.left, row)
47
+ const leftVal = await evaluateExpr({ node: node.left, row, tables })
41
48
  if (!leftVal) return false
42
- return Boolean(evaluateExpr(node.right, row))
49
+ return Boolean(await evaluateExpr({ node: node.right, row, tables }))
43
50
  }
44
51
 
45
52
  if (node.op === 'OR') {
46
- const leftVal = evaluateExpr(node.left, row)
53
+ const leftVal = await evaluateExpr({ node: node.left, row, tables })
47
54
  if (leftVal) return true
48
- return Boolean(evaluateExpr(node.right, row))
55
+ return Boolean(await evaluateExpr({ node: node.right, row, tables }))
49
56
  }
50
57
 
51
- const left = evaluateExpr(node.left, row)
52
- const right = evaluateExpr(node.right, row)
58
+ const left = await evaluateExpr({ node: node.left, row, tables })
59
+ const right = await evaluateExpr({ node: node.right, row, tables })
53
60
 
54
61
  // In SQL, NULL comparisons with =, !=, <> always return false (unknown)
55
62
  // You must use IS NULL or IS NOT NULL to check for NULL
@@ -83,9 +90,9 @@ export function evaluateExpr(node, row) {
83
90
 
84
91
  // BETWEEN and NOT BETWEEN
85
92
  if (node.type === 'between' || node.type === 'not between') {
86
- const expr = evaluateExpr(node.expr, row)
87
- const lower = evaluateExpr(node.lower, row)
88
- const upper = evaluateExpr(node.upper, row)
93
+ const expr = await evaluateExpr({ node: node.expr, row, tables })
94
+ const lower = await evaluateExpr({ node: node.lower, row, tables })
95
+ const upper = await evaluateExpr({ node: node.upper, row, tables })
89
96
 
90
97
  // If any value is NULL, return false (SQL behavior)
91
98
  if (expr == null || lower == null || upper == null) {
@@ -99,7 +106,7 @@ export function evaluateExpr(node, row) {
99
106
  // Function calls
100
107
  if (node.type === 'function') {
101
108
  const funcName = node.name.toUpperCase()
102
- const args = node.args.map(arg => evaluateExpr(arg, row))
109
+ const args = await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, tables })))
103
110
 
104
111
  if (funcName === 'UPPER') {
105
112
  if (args.length !== 1) throw new Error('UPPER requires exactly 1 argument')
@@ -161,11 +168,21 @@ export function evaluateExpr(node, row) {
161
168
  return String(val).trim()
162
169
  }
163
170
 
171
+ if (funcName === 'REPLACE') {
172
+ if (args.length !== 3) throw new Error('REPLACE requires exactly 3 arguments')
173
+ const str = args[0]
174
+ const searchStr = args[1]
175
+ const replaceStr = args[2]
176
+ // SQL REPLACE returns NULL if any argument is NULL
177
+ if (str == null || searchStr == null || replaceStr == null) return null
178
+ return String(str).replaceAll(String(searchStr), String(replaceStr))
179
+ }
180
+
164
181
  throw new Error('Unsupported function ' + funcName)
165
182
  }
166
183
 
167
184
  if (node.type === 'cast') {
168
- const val = evaluateExpr(node.expr, row)
185
+ const val = await evaluateExpr({ node: node.expr, row, tables })
169
186
  if (val == null) return null
170
187
  const toType = node.toType.toUpperCase()
171
188
  if (toType === 'INTEGER' || toType === 'INT') {
@@ -192,17 +209,17 @@ export function evaluateExpr(node, row) {
192
209
 
193
210
  // IN and NOT IN with value lists
194
211
  if (node.type === 'in valuelist') {
195
- const exprVal = evaluateExpr(node.expr, row)
212
+ const exprVal = await evaluateExpr({ node: node.expr, row, tables })
196
213
  for (const valueNode of node.values) {
197
- const val = evaluateExpr(valueNode, row)
214
+ const val = await evaluateExpr({ node: valueNode, row, tables })
198
215
  if (exprVal === val) return true
199
216
  }
200
217
  return false
201
218
  }
202
219
  if (node.type === 'not in valuelist') {
203
- const exprVal = evaluateExpr(node.expr, row)
220
+ const exprVal = await evaluateExpr({ node: node.expr, row, tables })
204
221
  for (const valueNode of node.values) {
205
- const val = evaluateExpr(valueNode, row)
222
+ const val = await evaluateExpr({ node: valueNode, row, tables })
206
223
  if (exprVal === val) return false
207
224
  }
208
225
  return true
@@ -210,45 +227,57 @@ export function evaluateExpr(node, row) {
210
227
 
211
228
  // IN and NOT IN with subqueries
212
229
  if (node.type === 'in') {
213
- throw new Error('WHERE IN with subqueries is not yet supported.')
230
+ const exprVal = await evaluateExpr({ node: node.expr, row, tables })
231
+ const results = await collect(executeSelect(node.subquery, tables))
232
+ if (results.length === 0) return false
233
+ const firstKey = Object.keys(results[0])[0]
234
+ const values = results.map(r => r[firstKey])
235
+ return values.includes(exprVal)
214
236
  }
215
237
  if (node.type === 'not in') {
216
- throw new Error('WHERE NOT IN with subqueries is not yet supported.')
238
+ const exprVal = await evaluateExpr({ node: node.expr, row, tables })
239
+ const results = await collect(executeSelect(node.subquery, tables))
240
+ if (results.length === 0) return true
241
+ const firstKey = Object.keys(results[0])[0]
242
+ const values = results.map(r => r[firstKey])
243
+ return !values.includes(exprVal)
217
244
  }
218
245
 
219
246
  // EXISTS and NOT EXISTS with subqueries
220
247
  if (node.type === 'exists') {
221
- throw new Error('WHERE EXISTS with subqueries is not yet supported.')
248
+ const results = await collect(executeSelect(node.subquery, tables))
249
+ return results.length > 0
222
250
  }
223
251
  if (node.type === 'not exists') {
224
- throw new Error('WHERE NOT EXISTS with subqueries is not yet supported.')
252
+ const results = await collect(executeSelect(node.subquery, tables))
253
+ return results.length === 0
225
254
  }
226
255
 
227
256
  // CASE expressions
228
257
  if (node.type === 'case') {
229
258
  // For simple CASE: evaluate the case expression once
230
- const caseValue = node.caseExpr ? evaluateExpr(node.caseExpr, row) : undefined
259
+ const caseValue = node.caseExpr ? await evaluateExpr({ node: node.caseExpr, row, tables }) : undefined
231
260
 
232
261
  // Iterate through WHEN clauses
233
262
  for (const whenClause of node.whenClauses) {
234
263
  let conditionResult
235
264
  if (caseValue !== undefined) {
236
265
  // Simple CASE: compare caseValue with condition
237
- const whenValue = evaluateExpr(whenClause.condition, row)
266
+ const whenValue = await evaluateExpr({ node: whenClause.condition, row, tables })
238
267
  conditionResult = caseValue === whenValue
239
268
  } else {
240
269
  // Searched CASE: evaluate condition as boolean
241
- conditionResult = evaluateExpr(whenClause.condition, row)
270
+ conditionResult = await evaluateExpr({ node: whenClause.condition, row, tables })
242
271
  }
243
272
 
244
273
  if (conditionResult) {
245
- return evaluateExpr(whenClause.result, row)
274
+ return evaluateExpr({ node: whenClause.result, row, tables })
246
275
  }
247
276
  }
248
277
 
249
278
  // No WHEN clause matched, return ELSE result or NULL
250
279
  if (node.elseResult) {
251
- return evaluateExpr(node.elseResult, row)
280
+ return evaluateExpr({ node: node.elseResult, row, tables })
252
281
  }
253
282
  return null
254
283
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AggregateFunc, ExprNode, RowSource, SqlPrimitive } from '../types.js'
2
+ * @import { AggregateFunc, AsyncDataSource, ExprNode, RowSource, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  import { isAggregateFunc } from '../validation.js'
@@ -43,9 +43,10 @@ function createHavingContext(resultRow, group) {
43
43
  * @param {ExprNode} expr - the HAVING expression
44
44
  * @param {Record<string, any>} row - the aggregated result row
45
45
  * @param {RowSource[]} group - the group of rows for re-evaluating aggregates
46
- * @returns {boolean} whether the HAVING condition is satisfied
46
+ * @param {Record<string, AsyncDataSource>} tables
47
+ * @returns {Promise<boolean>} whether the HAVING condition is satisfied
47
48
  */
48
- export function evaluateHavingExpr(expr, row, group) {
49
+ export async function evaluateHavingExpr(expr, row, group, tables) {
49
50
  const context = createHavingContext(row, group)
50
51
 
51
52
  // For HAVING, we need special handling of aggregate functions
@@ -54,13 +55,13 @@ export function evaluateHavingExpr(expr, row, group) {
54
55
  const funcName = expr.name.toUpperCase()
55
56
  if (isAggregateFunc(funcName)) {
56
57
  // Evaluate aggregate function on the group
57
- return Boolean(evaluateAggregateFunction(funcName, expr.args, group))
58
+ return Boolean(await evaluateAggregateFunction(funcName, expr.args, group, tables))
58
59
  }
59
60
  }
60
61
 
61
62
  if (expr.type === 'binary') {
62
- const left = evaluateHavingValue(expr.left, context, group)
63
- const right = evaluateHavingValue(expr.right, context, group)
63
+ const left = await evaluateHavingValue(expr.left, context, group, tables)
64
+ const right = await evaluateHavingValue(expr.right, context, group, tables)
64
65
 
65
66
  if (expr.op === 'AND') {
66
67
  return Boolean(left && right)
@@ -96,20 +97,20 @@ export function evaluateHavingExpr(expr, row, group) {
96
97
 
97
98
  if (expr.type === 'unary') {
98
99
  if (expr.op === 'NOT') {
99
- return !evaluateHavingExpr(expr.argument, context, group)
100
+ return !await evaluateHavingExpr(expr.argument, context, group, tables)
100
101
  }
101
102
  if (expr.op === 'IS NULL') {
102
- return evaluateHavingValue(expr.argument, context, group) == null
103
+ return await evaluateHavingValue(expr.argument, context, group, tables) == null
103
104
  }
104
105
  if (expr.op === 'IS NOT NULL') {
105
- return evaluateHavingValue(expr.argument, context, group) != null
106
+ return await evaluateHavingValue(expr.argument, context, group, tables) != null
106
107
  }
107
108
  }
108
109
 
109
110
  if (expr.type === 'between' || expr.type === 'not between') {
110
- const exprVal = evaluateHavingValue(expr.expr, context, group)
111
- const lower = evaluateHavingValue(expr.lower, context, group)
112
- const upper = evaluateHavingValue(expr.upper, context, group)
111
+ const exprVal = await evaluateHavingValue(expr.expr, context, group, tables)
112
+ const lower = await evaluateHavingValue(expr.lower, context, group, tables)
113
+ const upper = await evaluateHavingValue(expr.upper, context, group, tables)
113
114
 
114
115
  // If any value is NULL, return false (SQL behavior)
115
116
  if (exprVal == null || lower == null || upper == null) {
@@ -121,7 +122,7 @@ export function evaluateHavingExpr(expr, row, group) {
121
122
  }
122
123
 
123
124
  // For other expression types, use the context row
124
- return Boolean(evaluateExpr(expr, context))
125
+ return Boolean(await evaluateExpr({ node: expr, row: context, tables }))
125
126
  }
126
127
 
127
128
  /**
@@ -130,22 +131,23 @@ export function evaluateHavingExpr(expr, row, group) {
130
131
  * @param {ExprNode} expr
131
132
  * @param {RowSource} context - the context row
132
133
  * @param {RowSource[]} group - the group of rows
133
- * @returns {SqlPrimitive} the evaluated value
134
+ * @param {Record<string, AsyncDataSource>} tables
135
+ * @returns {Promise<SqlPrimitive>} the evaluated value
134
136
  */
135
- function evaluateHavingValue(expr, context, group) {
137
+ function evaluateHavingValue(expr, context, group, tables) {
136
138
  if (expr.type === 'function') {
137
139
  const funcName = expr.name.toUpperCase()
138
140
  if (isAggregateFunc(funcName)) {
139
- return evaluateAggregateFunction(funcName, expr.args, group)
141
+ return evaluateAggregateFunction(funcName, expr.args, group, tables)
140
142
  }
141
143
  }
142
144
 
143
145
  // For binary expressions, we need to use evaluateHavingExpr to properly handle aggregates
144
146
  if (expr.type === 'binary' || expr.type === 'unary' || expr.type === 'between' || expr.type === 'not between') {
145
- return evaluateHavingExpr(expr, context, group)
147
+ return evaluateHavingExpr(expr, context, group, tables)
146
148
  }
147
149
 
148
- return evaluateExpr(expr, context)
150
+ return evaluateExpr({ node: expr, row: context, tables })
149
151
  }
150
152
 
151
153
  /**
@@ -154,9 +156,10 @@ function evaluateHavingValue(expr, context, group) {
154
156
  * @param {AggregateFunc} funcName - aggregate function name
155
157
  * @param {ExprNode[]} args - function arguments
156
158
  * @param {RowSource[]} group - the group of rows
157
- * @returns {SqlPrimitive} the aggregate result
159
+ * @param {Record<string, AsyncDataSource>} tables
160
+ * @returns {Promise<SqlPrimitive>} the aggregate result
158
161
  */
159
- function evaluateAggregateFunction(funcName, args, group) {
162
+ async function evaluateAggregateFunction(funcName, args, group, tables) {
160
163
  if (funcName === 'COUNT') {
161
164
  if (args.length === 1 && args[0].type === 'identifier' && args[0].name === '*') {
162
165
  return group.length
@@ -164,7 +167,7 @@ function evaluateAggregateFunction(funcName, args, group) {
164
167
  // COUNT(column) - count non-null values
165
168
  let count = 0
166
169
  for (const row of group) {
167
- const val = evaluateExpr(args[0], row)
170
+ const val = await evaluateExpr({ node: args[0], row, tables })
168
171
  if (val != null) count++
169
172
  }
170
173
  return count
@@ -173,7 +176,7 @@ function evaluateAggregateFunction(funcName, args, group) {
173
176
  if (funcName === 'SUM') {
174
177
  let sum = 0
175
178
  for (const row of group) {
176
- const val = evaluateExpr(args[0], row)
179
+ const val = await evaluateExpr({ node: args[0], row, tables })
177
180
  if (val != null) sum += Number(val)
178
181
  }
179
182
  return sum
@@ -183,7 +186,7 @@ function evaluateAggregateFunction(funcName, args, group) {
183
186
  let sum = 0
184
187
  let count = 0
185
188
  for (const row of group) {
186
- const val = evaluateExpr(args[0], row)
189
+ const val = await evaluateExpr({ node: args[0], row, tables })
187
190
  if (val != null) {
188
191
  sum += Number(val)
189
192
  count++
@@ -195,7 +198,7 @@ function evaluateAggregateFunction(funcName, args, group) {
195
198
  if (funcName === 'MIN') {
196
199
  let min = null
197
200
  for (const row of group) {
198
- const val = evaluateExpr(args[0], row)
201
+ const val = await evaluateExpr({ node: args[0], row, tables })
199
202
  if (val != null && (min == null || val < min)) {
200
203
  min = val
201
204
  }
@@ -206,7 +209,7 @@ function evaluateAggregateFunction(funcName, args, group) {
206
209
  if (funcName === 'MAX') {
207
210
  let max = null
208
211
  for (const row of group) {
209
- const val = evaluateExpr(args[0], row)
212
+ const val = await evaluateExpr({ node: args[0], row, tables })
210
213
  if (val != null && (max == null || val > max)) {
211
214
  max = val
212
215
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Collects all results from an async generator into an array
3
+ *
4
+ * @template T
5
+ * @param {AsyncGenerator<T>} asyncGen - the async generator
6
+ * @returns {Promise<T[]>} array of all yielded values
7
+ */
8
+ export async function collect(asyncGen) {
9
+ const results = []
10
+ for await (const item of asyncGen) {
11
+ results.push(item)
12
+ }
13
+ return results
14
+ }
package/src/index.d.ts CHANGED
@@ -4,11 +4,11 @@ import type { ExecuteSqlOptions, SelectStatement } from './types.js'
4
4
  * Executes a SQL SELECT query against an array of data rows
5
5
  *
6
6
  * @param options
7
- * @param options.source - source data as a list of objects or a DataSource
8
- * @param options.sql - SQL query string
9
- * @returns rows matching the query
7
+ * @param options.tables - source data as a list of objects or an AsyncDataSource
8
+ * @param options.query - SQL query string
9
+ * @returns async generator yielding rows matching the query
10
10
  */
11
- export function executeSql(options: ExecuteSqlOptions): Record<string, any>[]
11
+ export function executeSql(options: ExecuteSqlOptions): AsyncGenerator<Record<string, any>>
12
12
 
13
13
  /**
14
14
  * Parses a SQL query string into an abstract syntax tree
@@ -17,3 +17,11 @@ export function executeSql(options: ExecuteSqlOptions): Record<string, any>[]
17
17
  * @returns parsed SQL select statement
18
18
  */
19
19
  export function parseSql(query: string): SelectStatement
20
+
21
+ /**
22
+ * Collects all results from an async generator into an array
23
+ *
24
+ * @param asyncGen - the async generator
25
+ * @returns array of all yielded values
26
+ */
27
+ export function collect<T>(asyncGen: AsyncGenerator<T>): Promise<T[]>
package/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { executeSql } from './execute/execute.js'
2
2
  export { parseSql } from './parse/parse.js'
3
+ export { collect } from './execute/utils.js'
package/src/types.d.ts CHANGED
@@ -1,26 +1,25 @@
1
+
2
+ /**
3
+ * Async data source for streaming SQL execution.
4
+ * Provides an async iterator over rows.
5
+ */
6
+ export interface AsyncDataSource {
7
+ getRows(): AsyncIterable<RowSource>
8
+ }
1
9
  export interface RowSource {
2
10
  getCell(name: string): any
3
11
  getKeys(): string[]
4
12
  }
5
13
 
6
- export interface DataSource {
7
- getNumRows(): number
8
- getRow(index: number): RowSource
9
- }
14
+ export type RawData = Record<string, any>[]
10
15
 
11
16
  export interface ExecuteSqlOptions {
12
- source: Record<string, any>[] | DataSource
17
+ tables: Record<string, RawData | AsyncDataSource>
13
18
  query: string
14
19
  }
15
20
 
16
21
  export type SqlPrimitive = string | number | bigint | boolean | null
17
22
 
18
- export interface FromSubquery {
19
- kind: 'subquery'
20
- query: SelectStatement
21
- alias: string
22
- }
23
-
24
23
  export interface SelectStatement {
25
24
  distinct: boolean
26
25
  columns: SelectColumn[]
@@ -34,6 +33,12 @@ export interface SelectStatement {
34
33
  offset?: number
35
34
  }
36
35
 
36
+ export interface FromSubquery {
37
+ kind: 'subquery'
38
+ query: SelectStatement
39
+ alias: string
40
+ }
41
+
37
42
  export type BinaryOp =
38
43
  | 'AND'
39
44
  | 'OR'
@@ -138,7 +143,7 @@ export interface StarColumn {
138
143
 
139
144
  export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX'
140
145
 
141
- export type StringFunc = 'UPPER' | 'LOWER' | 'CONCAT' | 'LENGTH' | 'SUBSTRING' | 'SUBSTR' | 'TRIM'
146
+ export type StringFunc = 'UPPER' | 'LOWER' | 'CONCAT' | 'LENGTH' | 'SUBSTRING' | 'SUBSTR' | 'TRIM' | 'REPLACE'
142
147
 
143
148
  export interface AggregateArgStar {
144
149
  kind: 'star'
package/src/validation.js CHANGED
@@ -13,5 +13,5 @@ export function isAggregateFunc(name) {
13
13
  * @returns {name is StringFunc}
14
14
  */
15
15
  export function isStringFunc(name) {
16
- return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM'].includes(name)
16
+ return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE'].includes(name)
17
17
  }