squirreling 0.12.0 → 0.12.2

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
@@ -35,7 +35,10 @@ const users = [
35
35
 
36
36
  // Squirreling return types
37
37
  interface QueryResults {
38
- rows: () => AsyncGenerator<AsyncRow>
38
+ columns: string[]
39
+ numRows?: number
40
+ maxRows?: number
41
+ rows(): AsyncGenerator<AsyncRow>
39
42
  }
40
43
  interface AsyncRow {
41
44
  columns: string[]
@@ -151,14 +154,14 @@ Squirreling mostly follows the SQL standard. The following features are supporte
151
154
 
152
155
  ### Functions
153
156
 
154
- - Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `JSON_ARRAYAGG`
157
+ - Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `JSON_ARRAYAGG`, `STRING_AGG`
155
158
  - String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`
156
159
  - Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
157
160
  - Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
158
161
  - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
159
- - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`
162
+ - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`
160
163
  - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
161
- - Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`
164
+ - Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
162
165
  - Spatial: `ST_GeomFromText`, `ST_MakeEnvelope`, `ST_AsText`, `ST_Intersects`, `ST_Contains`, `ST_ContainsProperly`, `ST_Within`, `ST_Overlaps`, `ST_Touches`, `ST_Equals`, `ST_Crosses`, `ST_Covers`, `ST_CoveredBy`, `ST_DWithin`
163
- - Conditional: `COALESCE`, `NULLIF`
166
+ - Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
164
167
  - User-defined functions (UDFs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
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.6.0",
43
+ "@vitest/coverage-v8": "4.1.4",
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.4"
48
48
  }
49
49
  }
@@ -15,7 +15,7 @@ export function asyncRow(obj, columns) {
15
15
  for (const key of columns) {
16
16
  cells[key] = () => Promise.resolve(obj[key])
17
17
  }
18
- return { columns, cells }
18
+ return { columns, cells, resolved: obj }
19
19
  }
20
20
 
21
21
  /**
@@ -34,13 +34,14 @@ export function memorySource({ data, columns }) {
34
34
  }
35
35
  const firstColumns = Object.keys(data[0])
36
36
  // Check first 1000 rows for consistent columns
37
+ const firstColSet = new Set(firstColumns)
37
38
  for (let i = 1; i < data.length && i < 1000; i++) {
38
39
  const rowColumns = Object.keys(data[i])
39
40
  const missing = firstColumns.find(col => !rowColumns.includes(col))
40
41
  if (missing) {
41
42
  throw new Error(`Inconsistent data, column "${missing}" not found in row ${i}`)
42
43
  }
43
- const extra = rowColumns.find(col => !firstColumns.includes(col))
44
+ const extra = rowColumns.find(col => !firstColSet.has(col))
44
45
  if (extra) {
45
46
  throw new Error(`Inconsistent data, unexpected column "${extra}" found in row ${i}`)
46
47
  }
@@ -54,11 +55,12 @@ export function memorySource({ data, columns }) {
54
55
  // Only apply offset and limit if no where clause
55
56
  const start = !where ? offset ?? 0 : 0
56
57
  const end = !where && limit !== undefined ? start + limit : data.length
58
+ const rowColumns = scanColumns ?? columns
57
59
  return {
58
60
  async *rows() {
59
61
  for (let i = start; i < end && i < data.length; i++) {
60
62
  if (signal?.aborted) break
61
- yield asyncRow(data[i], scanColumns ?? columns)
63
+ yield asyncRow(data[i], rowColumns)
62
64
  }
63
65
  },
64
66
  appliedWhere: false,
@@ -1,6 +1,6 @@
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
  /**
@@ -60,8 +60,9 @@ function projectAggregateColumns(selectColumns, group, context) {
60
60
  export function executeHashAggregate(plan, context) {
61
61
  const child = executePlan({ plan: plan.child, context })
62
62
  return {
63
+ columns: selectColumnNames(plan.columns, child.columns),
63
64
  maxRows: child.maxRows,
64
- async *rows () {
65
+ async *rows() {
65
66
  // Collect all rows
66
67
  /** @type {AsyncRow[]} */
67
68
  const allRows = []
@@ -119,9 +120,11 @@ export function executeHashAggregate(plan, context) {
119
120
  */
120
121
  export function executeScalarAggregate(plan, context) {
121
122
  // Fast path: use scanColumn when available
123
+ const scalarColumns = selectColumnNames(plan.columns, [])
122
124
  const fast = tryColumnScanAggregate(plan, context)
123
125
  if (fast) {
124
126
  return {
127
+ columns: scalarColumns,
125
128
  numRows: 1,
126
129
  maxRows: 1,
127
130
  rows: fast,
@@ -130,9 +133,10 @@ export function executeScalarAggregate(plan, context) {
130
133
 
131
134
  const child = executePlan({ plan: plan.child, context })
132
135
  return {
136
+ columns: selectColumnNames(plan.columns, child.columns),
133
137
  numRows: plan.having ? undefined : 1,
134
138
  maxRows: 1,
135
- async *rows () {
139
+ async *rows() {
136
140
  // Collect all rows into single group
137
141
  /** @type {AsyncRow[]} */
138
142
  const group = []
@@ -10,7 +10,7 @@ import { executeSort } from './sort.js'
10
10
  import { addBounds, minBounds, stableRowKey } from './utils.js'
11
11
 
12
12
  /**
13
- * @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, QueryResults, Statement } from '../types.js'
13
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, QueryResults, SelectColumn, SqlPrimitive, Statement } from '../types.js'
14
14
  * @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode } from '../plan/types.js'
15
15
  */
16
16
 
@@ -24,14 +24,27 @@ export function executeSql({ tables, query, functions, signal }) {
24
24
  const parsed = typeof query === 'string' ? parseSql({ query, functions }) : query
25
25
 
26
26
  // Normalize tables: convert arrays to AsyncDataSource
27
+ // Fast path: skip normalization when no arrays are present
28
+ let needsNormalization = false
29
+ const tableKeys = Object.keys(tables)
30
+ for (let i = 0; i < tableKeys.length; i++) {
31
+ if (Array.isArray(tables[tableKeys[i]])) {
32
+ needsNormalization = true
33
+ break
34
+ }
35
+ }
36
+
27
37
  /** @type {Record<string, AsyncDataSource>} */
28
- const normalizedTables = {}
29
- for (const [name, data] of Object.entries(tables)) {
30
- if (Array.isArray(data)) {
31
- normalizedTables[name] = memorySource({ data })
32
- } else {
33
- normalizedTables[name] = data
38
+ let normalizedTables
39
+ if (needsNormalization) {
40
+ normalizedTables = {}
41
+ for (let i = 0; i < tableKeys.length; i++) {
42
+ const name = tableKeys[i]
43
+ const data = tables[name]
44
+ normalizedTables[name] = Array.isArray(data) ? memorySource({ data }) : data
34
45
  }
46
+ } else {
47
+ normalizedTables = /** @type {Record<string, AsyncDataSource>} */ (tables)
35
48
  }
36
49
 
37
50
  const context = { tables: normalizedTables, functions, signal }
@@ -88,7 +101,33 @@ export function executePlan({ plan, context }) {
88
101
  } else if (plan.type === 'SetOperation') {
89
102
  return executeSetOperation(plan, context)
90
103
  }
91
- return { async *rows () {} }
104
+ return { columns: [], async *rows() {} }
105
+ }
106
+
107
+ /**
108
+ * Derives output column names from SELECT columns and available child columns.
109
+ *
110
+ * @param {SelectColumn[]} selectColumns
111
+ * @param {string[]} childColumns
112
+ * @returns {string[]}
113
+ */
114
+ export function selectColumnNames(selectColumns, childColumns) {
115
+ /** @type {string[]} */
116
+ const result = []
117
+ for (const col of selectColumns) {
118
+ if (col.type === 'star') {
119
+ const prefix = col.table ? `${col.table}.` : undefined
120
+ for (const key of childColumns) {
121
+ if (prefix && !key.startsWith(prefix)) continue
122
+ const dotIndex = key.indexOf('.')
123
+ const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
124
+ result.push(outputKey)
125
+ }
126
+ } else {
127
+ result.push(col.alias ?? derivedAlias(col.expr))
128
+ }
129
+ }
130
+ return result
92
131
  }
93
132
 
94
133
  /**
@@ -113,9 +152,10 @@ function executeScan(plan, context) {
113
152
  })
114
153
  const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
115
154
  return {
155
+ columns: [column],
116
156
  numRows: scanRows,
117
157
  maxRows: scanRows,
118
- async *rows () {
158
+ async *rows() {
119
159
  const columns = [column]
120
160
  for await (const chunk of chunks) {
121
161
  if (signal?.aborted) return
@@ -142,9 +182,10 @@ function executeScan(plan, context) {
142
182
 
143
183
  const scanRows = computeScanRows(table.numRows, plan.hints.limit, plan.hints.offset)
144
184
  return {
185
+ columns: plan.hints.columns ?? table.columns,
145
186
  numRows: !plan.hints.where ? scanRows : undefined,
146
187
  maxRows: scanRows,
147
- async *rows () {
188
+ async *rows() {
148
189
  let result = scanResult.rows()
149
190
 
150
191
  // Apply WHERE if data source did not
@@ -174,9 +215,10 @@ function executeCount(plan, context) {
174
215
  const table = validateTable({ ...plan, tables })
175
216
 
176
217
  return {
218
+ columns: plan.columns.map(col => col.alias ?? derivedAlias(col.expr)),
177
219
  numRows: 1,
178
220
  maxRows: 1,
179
- async *rows () {
221
+ async *rows() {
180
222
  // Use source numRows if available
181
223
  let count = table.numRows
182
224
  if (count === undefined) {
@@ -300,6 +342,7 @@ async function* limitRows(rows, limit, offset, signal) {
300
342
  function executeFilter(plan, context) {
301
343
  const child = executePlan({ plan: plan.child, context })
302
344
  return {
345
+ columns: child.columns,
303
346
  maxRows: child.maxRows,
304
347
  rows: () => filterRows(child.rows(), plan.condition, context),
305
348
  }
@@ -314,36 +357,86 @@ function executeFilter(plan, context) {
314
357
  */
315
358
  function executeProject(plan, context) {
316
359
  const child = executePlan({ plan: plan.child, context })
360
+
361
+ // Pre-compute column names for derived columns (avoids per-row derivedAlias calls)
362
+ const hasStar = plan.columns.some(col => col.type === 'star')
363
+
364
+ /** @type {string[] | undefined} */
365
+ let staticColumns
366
+ /** @type {{ alias: string, sourceName: string }[] | undefined} */
367
+ let identifierMap
368
+ if (!hasStar) {
369
+ const derived = /** @type {DerivedColumn[]} */ (plan.columns)
370
+ staticColumns = derived.map(col => col.alias ?? derivedAlias(col.expr))
371
+ const allIdentifiers = derived.every(col =>
372
+ col.expr.type === 'identifier' && !col.expr.prefix
373
+ )
374
+ if (allIdentifiers) {
375
+ identifierMap = derived.map((col, i) => ({
376
+ alias: staticColumns[i],
377
+ sourceName: /** @type {IdentifierNode} */ (col.expr).name,
378
+ }))
379
+ }
380
+ }
381
+
317
382
  return {
383
+ columns: selectColumnNames(plan.columns, child.columns),
318
384
  numRows: child.numRows,
319
385
  maxRows: child.maxRows,
320
- async *rows () {
386
+ async *rows() {
321
387
  let rowIndex = 0
388
+ let identifierMapValidated = false
322
389
 
323
390
  for await (const row of child.rows()) {
324
391
  if (context.signal?.aborted) return
325
392
  rowIndex++
393
+
394
+ // Validate identifier fast path on first row (may fail for JOINs with prefixed columns)
395
+ if (identifierMap && !identifierMapValidated) {
396
+ identifierMapValidated = true
397
+ if (!identifierMap.every(m => m.sourceName in row.cells)) {
398
+ identifierMap = undefined
399
+ }
400
+ }
401
+
402
+ // Fast path: all columns are simple identifier references
403
+ if (identifierMap) {
404
+ /** @type {AsyncCells} */
405
+ const cells = {}
406
+ const source = row.resolved
407
+ /** @type {Record<string, SqlPrimitive> | undefined} */
408
+ const resolved = source ? {} : undefined
409
+ for (const { alias, sourceName } of identifierMap) {
410
+ cells[alias] = row.cells[sourceName]
411
+ if (resolved && source) resolved[alias] = source[sourceName]
412
+ }
413
+ yield resolved
414
+ ? { columns: staticColumns, cells, resolved }
415
+ : { columns: staticColumns, cells }
416
+ continue
417
+ }
418
+
326
419
  const currentRowIndex = rowIndex
327
420
 
328
421
  /** @type {string[]} */
329
- const columns = []
422
+ const columns = staticColumns ?? []
330
423
  /** @type {AsyncCells} */
331
424
  const cells = {}
332
425
 
333
- for (const col of plan.columns) {
426
+ for (let i = 0; i < plan.columns.length; i++) {
427
+ const col = plan.columns[i]
334
428
  if (col.type === 'star') {
335
429
  const prefix = col.table ? `${col.table}.` : undefined
336
430
  for (const key of row.columns) {
337
431
  if (prefix && !key.startsWith(prefix)) continue
338
- // Strip table prefix for output column names
339
432
  const dotIndex = key.indexOf('.')
340
433
  const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
341
434
  columns.push(outputKey)
342
435
  cells[outputKey] = row.cells[key]
343
436
  }
344
437
  } else {
345
- const alias = col.alias ?? derivedAlias(col.expr)
346
- columns.push(alias)
438
+ const alias = staticColumns ? staticColumns[i] : (col.alias ?? derivedAlias(col.expr))
439
+ if (!staticColumns) columns.push(alias)
347
440
  cells[alias] = () => evaluateExpr({
348
441
  node: col.expr,
349
442
  row,
@@ -369,8 +462,9 @@ function executeProject(plan, context) {
369
462
  function executeDistinct(plan, context) {
370
463
  const child = executePlan({ plan: plan.child, context })
371
464
  return {
465
+ columns: child.columns,
372
466
  maxRows: child.maxRows,
373
- async *rows () {
467
+ async *rows() {
374
468
  const { signal } = context
375
469
  const MAX_CHUNK = 256
376
470
 
@@ -421,6 +515,7 @@ function executeDistinct(plan, context) {
421
515
  function executeLimit(plan, context) {
422
516
  const child = executePlan({ plan: plan.child, context })
423
517
  return {
518
+ columns: child.columns,
424
519
  numRows: computeScanRows(child.numRows, plan.limit, plan.offset),
425
520
  maxRows: computeScanRows(child.maxRows, plan.limit, plan.offset),
426
521
  rows: () => limitRows(child.rows(), plan.limit, plan.offset, context.signal),
@@ -442,9 +537,10 @@ function executeSetOperation(plan, context) {
442
537
  const left = executePlan({ plan: plan.left, context })
443
538
  const right = executePlan({ plan: plan.right, context })
444
539
  return {
540
+ columns: left.columns,
445
541
  numRows: addBounds(left.numRows, right.numRows),
446
542
  maxRows: addBounds(left.maxRows, right.maxRows),
447
- async *rows () {
543
+ async *rows() {
448
544
  // UNION ALL: yield all rows from both sides
449
545
  yield* left.rows()
450
546
  yield* right.rows()
@@ -454,8 +550,9 @@ function executeSetOperation(plan, context) {
454
550
  const left = executePlan({ plan: plan.left, context })
455
551
  const right = executePlan({ plan: plan.right, context })
456
552
  return {
553
+ columns: left.columns,
457
554
  maxRows: addBounds(left.maxRows, right.maxRows),
458
- async *rows () {
555
+ async *rows() {
459
556
  // UNION: yield deduplicated rows from both sides
460
557
  const seen = new Set()
461
558
  for await (const row of left.rows()) {
@@ -481,8 +578,9 @@ function executeSetOperation(plan, context) {
481
578
  const left = executePlan({ plan: plan.left, context })
482
579
  const right = executePlan({ plan: plan.right, context })
483
580
  return {
581
+ columns: left.columns,
484
582
  maxRows: minBounds(left.maxRows, right.maxRows),
485
- async *rows () {
583
+ async *rows() {
486
584
  // Materialize right side keys
487
585
  /** @type {Map<any, number>} */
488
586
  const rightKeys = new Map()
@@ -522,8 +620,9 @@ function executeSetOperation(plan, context) {
522
620
  const left = executePlan({ plan: plan.left, context })
523
621
  const right = executePlan({ plan: plan.right, context })
524
622
  return {
623
+ columns: left.columns,
525
624
  maxRows: left.maxRows,
526
- async *rows () {
625
+ async *rows() {
527
626
  // Materialize right side keys
528
627
  /** @type {Map<any, number>} */
529
628
  const rightKeys = new Map()
@@ -18,7 +18,8 @@ export function executeNestedLoopJoin(plan, context) {
18
18
  const left = executePlan({ plan: plan.left, context })
19
19
  const right = executePlan({ plan: plan.right, context })
20
20
  return {
21
- async *rows () {
21
+ columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
22
+ async *rows() {
22
23
  const leftTable = plan.leftAlias
23
24
  const rightTable = plan.rightAlias
24
25
 
@@ -93,9 +94,10 @@ export function executePositionalJoin(plan, context) {
93
94
  const numRows = left.numRows !== undefined && right.numRows !== undefined
94
95
  ? Math.max(left.numRows, right.numRows) : undefined
95
96
  return {
97
+ columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
96
98
  numRows,
97
99
  maxRows: maxBounds(left.maxRows, right.maxRows),
98
- async *rows () {
100
+ async *rows() {
99
101
  const { signal } = context
100
102
  const leftTable = plan.leftAlias
101
103
  const rightTable = plan.rightAlias
@@ -140,7 +142,8 @@ export function executeHashJoin(plan, context) {
140
142
  const left = executePlan({ plan: plan.left, context })
141
143
  const right = executePlan({ plan: plan.right, context })
142
144
  return {
143
- async *rows () {
145
+ columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
146
+ async *rows() {
144
147
  const leftTable = plan.leftAlias
145
148
  const rightTable = plan.rightAlias
146
149
 
@@ -233,6 +236,22 @@ function createNullRow(columns) {
233
236
  return { columns, cells }
234
237
  }
235
238
 
239
+ /**
240
+ * Merges column name arrays with table prefixes, matching mergeRows logic.
241
+ *
242
+ * @param {string[]} leftColumns
243
+ * @param {string[]} rightColumns
244
+ * @param {string} leftTable
245
+ * @param {string} rightTable
246
+ * @returns {string[]}
247
+ */
248
+ function mergeColumnNames(leftColumns, rightColumns, leftTable, rightTable) {
249
+ return [
250
+ ...leftColumns.map(c => c.includes('.') ? c : `${leftTable}.${c}`),
251
+ ...rightColumns.map(c => c.includes('.') ? c : `${rightTable}.${c}`),
252
+ ]
253
+ }
254
+
236
255
  /**
237
256
  * Merges two rows into one, prefixing columns with table names
238
257
  *
@@ -17,9 +17,10 @@ import { compareForTerm } from './utils.js'
17
17
  export function executeSort(plan, context) {
18
18
  const child = executePlan({ plan: plan.child, context })
19
19
  return {
20
+ columns: child.columns,
20
21
  numRows: child.numRows,
21
22
  maxRows: child.maxRows,
22
- async *rows () {
23
+ async *rows() {
23
24
  // Buffer all rows
24
25
  /** @type {AsyncRow[]} */
25
26
  const rows = []
@@ -2,6 +2,8 @@
2
2
  * @import { AsyncRow, OrderByItem, QueryResults, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
+ const primitiveTypes = new Set(['number', 'bigint', 'boolean', 'string'])
6
+
5
7
  /**
6
8
  * Compares two values for a single ORDER BY term, handling nulls and direction
7
9
  *
@@ -24,10 +26,9 @@ export function compareForTerm(a, b, term) {
24
26
  // Compare non-null values
25
27
  if (a == b) return 0
26
28
 
27
- const primitives = ['number', 'bigint', 'boolean', 'string']
28
29
  let cmp
29
- if (primitives.includes(typeof a) && primitives.includes(typeof b)) {
30
- cmp = a < b ? -1 : a > b ? 1 : 0
30
+ if (primitiveTypes.has(typeof a) && primitiveTypes.has(typeof b)) {
31
+ cmp = a < b ? -1 : 1
31
32
  } else {
32
33
  const aa = String(a)
33
34
  const bb = String(b)
@@ -51,6 +52,29 @@ export async function collect(results) {
51
52
  for await (const asyncRow of results.rows()) {
52
53
  rows.push(asyncRow)
53
54
  }
55
+
56
+ // Fast path: if all rows have pre-materialized data, skip Promise overhead
57
+ let allMaterialized = rows.length > 0
58
+ for (let i = 0; i < rows.length; i++) {
59
+ if (!rows[i].resolved) {
60
+ allMaterialized = false
61
+ break
62
+ }
63
+ }
64
+ if (allMaterialized) {
65
+ const result = new Array(rows.length)
66
+ for (let i = 0; i < rows.length; i++) {
67
+ const row = rows[i]
68
+ /** @type {Record<string, SqlPrimitive>} */
69
+ const item = {}
70
+ for (const col of row.columns) {
71
+ item[col] = row.resolved[col]
72
+ }
73
+ result[i] = item
74
+ }
75
+ return result
76
+ }
77
+
54
78
  return Promise.all(rows.map(async asyncRow => {
55
79
  const values = await Promise.all(asyncRow.columns.map(k => asyncRow.cells[k]()))
56
80
  /** @type {Record<string, SqlPrimitive>} */
@@ -272,6 +272,32 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
272
272
  ))
273
273
  }
274
274
  }
275
+
276
+ if (funcName === 'STRING_AGG') {
277
+ const separatorNode = node.args[1]
278
+ const separator = String(await evaluateExpr({ node: separatorNode, row: filteredRows[0] ?? { columns: [], cells: {} }, context }))
279
+ /** @type {string[]} */
280
+ const values = []
281
+ if (node.distinct) {
282
+ const seen = new Set()
283
+ for (const row of filteredRows) {
284
+ const v = await evaluateExpr({ node: argNode, row, context })
285
+ if (v == null) continue
286
+ const str = String(v)
287
+ const key = keyify(str)
288
+ if (!seen.has(key)) {
289
+ seen.add(key)
290
+ values.push(str)
291
+ }
292
+ }
293
+ } else {
294
+ for (const row of filteredRows) {
295
+ const v = await evaluateExpr({ node: argNode, row, context })
296
+ if (v != null) values.push(String(v))
297
+ }
298
+ }
299
+ return values.length === 0 ? null : values.join(separator)
300
+ }
275
301
  }
276
302
 
277
303
  /** @type {SqlPrimitive[]} */
@@ -311,6 +337,20 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
311
337
  return val1 == await val2 ? null : val1
312
338
  }
313
339
 
340
+ if (funcName === 'GREATEST' || funcName === 'LEAST') {
341
+ // Skip nulls; return null if all inputs are null
342
+ const isGreatest = funcName === 'GREATEST'
343
+ /** @type {SqlPrimitive} */
344
+ let best = null
345
+ for (const arg of args) {
346
+ if (arg == null) continue
347
+ if (best == null || (isGreatest ? arg > best : arg < best)) {
348
+ best = arg
349
+ }
350
+ }
351
+ return best
352
+ }
353
+
314
354
  if (funcName === 'DATE_TRUNC') {
315
355
  return dateTrunc(args[0], args[1])
316
356
  }
@@ -357,6 +397,25 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
357
397
  return result
358
398
  }
359
399
 
400
+ if (funcName === 'JSON_ARRAY_LENGTH') {
401
+ let arr = args[0]
402
+ if (arr == null) return null
403
+ if (typeof arr === 'string') {
404
+ try {
405
+ arr = JSON.parse(arr)
406
+ } catch {
407
+ throw new ArgValueError({
408
+ ...node,
409
+ message: 'invalid JSON string',
410
+ hint: 'Argument must be valid JSON.',
411
+ rowIndex,
412
+ })
413
+ }
414
+ }
415
+ if (!Array.isArray(arr)) return null
416
+ return arr.length
417
+ }
418
+
360
419
  if (funcName === 'ARRAY_LENGTH' || funcName === 'CARDINALITY') {
361
420
  const arr = args[0]
362
421
  if (!Array.isArray(arr)) return null
@@ -78,6 +78,27 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
78
78
  return null
79
79
  }
80
80
 
81
+ if (funcName === 'REGEXP_MATCHES') {
82
+ const str = args[0]
83
+ const pattern = args[1]
84
+ if (str == null || pattern == null) return null
85
+ const strVal = String(str)
86
+ const patternStr = String(pattern)
87
+
88
+ let regex
89
+ try {
90
+ regex = new RegExp(patternStr)
91
+ } catch (/** @type {any} */ error) {
92
+ throw new ArgValueError({
93
+ ...node,
94
+ message: `invalid regex pattern: ${error.message}`,
95
+ rowIndex,
96
+ })
97
+ }
98
+
99
+ return regex.test(strVal)
100
+ }
101
+
81
102
  if (funcName === 'REGEXP_REPLACE') {
82
103
  const str = args[0]
83
104
  const pattern = args[1]
package/src/types.d.ts CHANGED
@@ -8,6 +8,7 @@ export { QueryPlan } from './plan/types.js'
8
8
  * Result of executing a SQL query.
9
9
  */
10
10
  export interface QueryResults {
11
+ columns: string[]
11
12
  rows(): AsyncGenerator<AsyncRow>
12
13
  numRows?: number
13
14
  maxRows?: number
@@ -45,6 +46,9 @@ export interface ExecuteContext {
45
46
  export interface AsyncRow {
46
47
  columns: string[]
47
48
  cells: AsyncCells
49
+ // Optional pre-materialized row values keyed by output column name.
50
+ // When present, consumers can skip the AsyncCell Promise roundtrip.
51
+ resolved?: Record<string, SqlPrimitive>
48
52
  }
49
53
  export type AsyncCells = Record<string, AsyncCell>
50
54
  export type AsyncCell = () => Promise<SqlPrimitive>
@@ -68,7 +72,6 @@ export interface AsyncDataSource {
68
72
  */
69
73
  export interface ScanResults {
70
74
  rows(): AsyncIterable<AsyncRow>
71
- numRows?: number // exact row count if known
72
75
  appliedWhere: boolean // WHERE filter applied at scan time?
73
76
  appliedLimitOffset: boolean // LIMIT and OFFSET applied at scan time?
74
77
  }
@@ -110,9 +113,9 @@ export interface UserDefinedFunction {
110
113
  arguments: FunctionSignature
111
114
  }
112
115
 
113
- export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE'
116
+ export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
114
117
 
115
- export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE'
118
+ export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES'
116
119
 
117
120
  export type MathFunc =
118
121
  | 'FLOOR'
@@ -11,7 +11,7 @@ export const niladicFuncs = ['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP'
11
11
  * @returns {name is AggregateFunc}
12
12
  */
13
13
  export function isAggregateFunc(name) {
14
- return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE'].includes(name)
14
+ return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
15
15
  }
16
16
 
17
17
  /**
@@ -31,7 +31,7 @@ export function isMathFunc(name) {
31
31
  * @returns {name is RegExpFunction}
32
32
  */
33
33
  export function isRegexpFunc(name) {
34
- return ['REGEXP_SUBSTR', 'REGEXP_EXTRACT', 'REGEXP_REPLACE'].includes(name)
34
+ return ['REGEXP_SUBSTR', 'REGEXP_EXTRACT', 'REGEXP_REPLACE', 'REGEXP_MATCHES'].includes(name)
35
35
  }
36
36
 
37
37
  /**
@@ -112,6 +112,7 @@ export const FUNCTION_SIGNATURES = {
112
112
  REGEXP_SUBSTR: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
113
113
  REGEXP_EXTRACT: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
114
114
  REGEXP_REPLACE: { min: 3, max: 5, signature: 'string, pattern, replacement[, position[, occurrence]]' },
115
+ REGEXP_MATCHES: { min: 2, max: 2, signature: 'string, pattern' },
115
116
 
116
117
  // Date/time functions
117
118
  RANDOM: { min: 0, max: 0, signature: '' },
@@ -154,6 +155,7 @@ export const FUNCTION_SIGNATURES = {
154
155
  JSON_QUERY: { min: 2, max: 2, signature: 'expression, path' },
155
156
  JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
156
157
  JSON_OBJECT: { min: 0, signature: 'key1, value1[, ...]' },
158
+ JSON_ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
157
159
  JSON_ARRAYAGG: { min: 1, max: 1, signature: 'expression' },
158
160
 
159
161
  // Array functions
@@ -165,6 +167,8 @@ export const FUNCTION_SIGNATURES = {
165
167
  // Conditional functions
166
168
  COALESCE: { min: 1, signature: 'value1, value2[, ...]' },
167
169
  NULLIF: { min: 2, max: 2, signature: 'value1, value2' },
170
+ GREATEST: { min: 1, signature: 'value1[, value2, ...]' },
171
+ LEAST: { min: 1, signature: 'value1[, value2, ...]' },
168
172
 
169
173
  // Aggregate functions
170
174
  COUNT: { min: 1, max: 1, signature: 'expression' },
@@ -177,6 +181,7 @@ export const FUNCTION_SIGNATURES = {
177
181
  MEDIAN: { min: 1, max: 1, signature: 'expression' },
178
182
  PERCENTILE_CONT: { min: 2, max: 2, signature: 'fraction, expression' },
179
183
  APPROX_QUANTILE: { min: 2, max: 2, signature: 'expression, fraction' },
184
+ STRING_AGG: { min: 2, max: 2, signature: 'expression, separator' },
180
185
 
181
186
  // Spatial functions
182
187
  ST_INTERSECTS: { min: 2, max: 2, signature: 'geometry, geometry' },