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 +8 -5
- package/package.json +4 -4
- package/src/backend/dataSource.js +5 -3
- package/src/execute/aggregates.js +7 -3
- package/src/execute/execute.js +121 -22
- package/src/execute/join.js +22 -3
- package/src/execute/sort.js +2 -1
- package/src/execute/utils.js +27 -3
- package/src/expression/evaluate.js +59 -0
- package/src/expression/regexp.js +21 -0
- package/src/types.d.ts +6 -3
- package/src/validation/functions.js +7 -2
package/README.md
CHANGED
|
@@ -35,7 +35,10 @@ const users = [
|
|
|
35
35
|
|
|
36
36
|
// Squirreling return types
|
|
37
37
|
interface QueryResults {
|
|
38
|
-
|
|
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.
|
|
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.
|
|
43
|
-
"@vitest/coverage-v8": "4.1.
|
|
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.
|
|
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 => !
|
|
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],
|
|
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 = []
|
package/src/execute/execute.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 (
|
|
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()
|
package/src/execute/join.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
*
|
package/src/execute/sort.js
CHANGED
|
@@ -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 = []
|
package/src/execute/utils.js
CHANGED
|
@@ -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 (
|
|
30
|
-
cmp = a < b ? -1 :
|
|
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
|
package/src/expression/regexp.js
CHANGED
|
@@ -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' },
|