squirreling 0.12.1 → 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 +4 -4
- package/package.json +4 -4
- package/src/backend/dataSource.js +5 -3
- package/src/execute/aggregates.js +2 -2
- package/src/execute/execute.js +84 -22
- package/src/execute/join.js +3 -3
- package/src/execute/sort.js +1 -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 +5 -2
- package/src/validation/functions.js +7 -2
package/README.md
CHANGED
|
@@ -154,14 +154,14 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
154
154
|
|
|
155
155
|
### Functions
|
|
156
156
|
|
|
157
|
-
- 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`
|
|
158
158
|
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`
|
|
159
159
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
160
160
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
161
161
|
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
|
|
162
|
-
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`
|
|
162
|
+
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`
|
|
163
163
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
|
|
164
|
-
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`
|
|
164
|
+
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
|
|
165
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`
|
|
166
|
-
- Conditional: `COALESCE`, `NULLIF`
|
|
166
|
+
- Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
|
|
167
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,
|
|
@@ -62,7 +62,7 @@ export function executeHashAggregate(plan, context) {
|
|
|
62
62
|
return {
|
|
63
63
|
columns: selectColumnNames(plan.columns, child.columns),
|
|
64
64
|
maxRows: child.maxRows,
|
|
65
|
-
async *rows
|
|
65
|
+
async *rows() {
|
|
66
66
|
// Collect all rows
|
|
67
67
|
/** @type {AsyncRow[]} */
|
|
68
68
|
const allRows = []
|
|
@@ -136,7 +136,7 @@ export function executeScalarAggregate(plan, context) {
|
|
|
136
136
|
columns: selectColumnNames(plan.columns, child.columns),
|
|
137
137
|
numRows: plan.having ? undefined : 1,
|
|
138
138
|
maxRows: 1,
|
|
139
|
-
async *rows
|
|
139
|
+
async *rows() {
|
|
140
140
|
// Collect all rows into single group
|
|
141
141
|
/** @type {AsyncRow[]} */
|
|
142
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, SelectColumn, 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,7 @@ export function executePlan({ plan, context }) {
|
|
|
88
101
|
} else if (plan.type === 'SetOperation') {
|
|
89
102
|
return executeSetOperation(plan, context)
|
|
90
103
|
}
|
|
91
|
-
return { columns: [], async *rows
|
|
104
|
+
return { columns: [], async *rows() {} }
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
/**
|
|
@@ -142,7 +155,7 @@ function executeScan(plan, context) {
|
|
|
142
155
|
columns: [column],
|
|
143
156
|
numRows: scanRows,
|
|
144
157
|
maxRows: scanRows,
|
|
145
|
-
async *rows
|
|
158
|
+
async *rows() {
|
|
146
159
|
const columns = [column]
|
|
147
160
|
for await (const chunk of chunks) {
|
|
148
161
|
if (signal?.aborted) return
|
|
@@ -172,7 +185,7 @@ function executeScan(plan, context) {
|
|
|
172
185
|
columns: plan.hints.columns ?? table.columns,
|
|
173
186
|
numRows: !plan.hints.where ? scanRows : undefined,
|
|
174
187
|
maxRows: scanRows,
|
|
175
|
-
async *rows
|
|
188
|
+
async *rows() {
|
|
176
189
|
let result = scanResult.rows()
|
|
177
190
|
|
|
178
191
|
// Apply WHERE if data source did not
|
|
@@ -205,7 +218,7 @@ function executeCount(plan, context) {
|
|
|
205
218
|
columns: plan.columns.map(col => col.alias ?? derivedAlias(col.expr)),
|
|
206
219
|
numRows: 1,
|
|
207
220
|
maxRows: 1,
|
|
208
|
-
async *rows
|
|
221
|
+
async *rows() {
|
|
209
222
|
// Use source numRows if available
|
|
210
223
|
let count = table.numRows
|
|
211
224
|
if (count === undefined) {
|
|
@@ -344,37 +357,86 @@ function executeFilter(plan, context) {
|
|
|
344
357
|
*/
|
|
345
358
|
function executeProject(plan, context) {
|
|
346
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
|
+
|
|
347
382
|
return {
|
|
348
383
|
columns: selectColumnNames(plan.columns, child.columns),
|
|
349
384
|
numRows: child.numRows,
|
|
350
385
|
maxRows: child.maxRows,
|
|
351
|
-
async *rows
|
|
386
|
+
async *rows() {
|
|
352
387
|
let rowIndex = 0
|
|
388
|
+
let identifierMapValidated = false
|
|
353
389
|
|
|
354
390
|
for await (const row of child.rows()) {
|
|
355
391
|
if (context.signal?.aborted) return
|
|
356
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
|
+
|
|
357
419
|
const currentRowIndex = rowIndex
|
|
358
420
|
|
|
359
421
|
/** @type {string[]} */
|
|
360
|
-
const columns = []
|
|
422
|
+
const columns = staticColumns ?? []
|
|
361
423
|
/** @type {AsyncCells} */
|
|
362
424
|
const cells = {}
|
|
363
425
|
|
|
364
|
-
for (
|
|
426
|
+
for (let i = 0; i < plan.columns.length; i++) {
|
|
427
|
+
const col = plan.columns[i]
|
|
365
428
|
if (col.type === 'star') {
|
|
366
429
|
const prefix = col.table ? `${col.table}.` : undefined
|
|
367
430
|
for (const key of row.columns) {
|
|
368
431
|
if (prefix && !key.startsWith(prefix)) continue
|
|
369
|
-
// Strip table prefix for output column names
|
|
370
432
|
const dotIndex = key.indexOf('.')
|
|
371
433
|
const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
|
|
372
434
|
columns.push(outputKey)
|
|
373
435
|
cells[outputKey] = row.cells[key]
|
|
374
436
|
}
|
|
375
437
|
} else {
|
|
376
|
-
const alias = col.alias ?? derivedAlias(col.expr)
|
|
377
|
-
columns.push(alias)
|
|
438
|
+
const alias = staticColumns ? staticColumns[i] : (col.alias ?? derivedAlias(col.expr))
|
|
439
|
+
if (!staticColumns) columns.push(alias)
|
|
378
440
|
cells[alias] = () => evaluateExpr({
|
|
379
441
|
node: col.expr,
|
|
380
442
|
row,
|
|
@@ -402,7 +464,7 @@ function executeDistinct(plan, context) {
|
|
|
402
464
|
return {
|
|
403
465
|
columns: child.columns,
|
|
404
466
|
maxRows: child.maxRows,
|
|
405
|
-
async *rows
|
|
467
|
+
async *rows() {
|
|
406
468
|
const { signal } = context
|
|
407
469
|
const MAX_CHUNK = 256
|
|
408
470
|
|
|
@@ -478,7 +540,7 @@ function executeSetOperation(plan, context) {
|
|
|
478
540
|
columns: left.columns,
|
|
479
541
|
numRows: addBounds(left.numRows, right.numRows),
|
|
480
542
|
maxRows: addBounds(left.maxRows, right.maxRows),
|
|
481
|
-
async *rows
|
|
543
|
+
async *rows() {
|
|
482
544
|
// UNION ALL: yield all rows from both sides
|
|
483
545
|
yield* left.rows()
|
|
484
546
|
yield* right.rows()
|
|
@@ -490,7 +552,7 @@ function executeSetOperation(plan, context) {
|
|
|
490
552
|
return {
|
|
491
553
|
columns: left.columns,
|
|
492
554
|
maxRows: addBounds(left.maxRows, right.maxRows),
|
|
493
|
-
async *rows
|
|
555
|
+
async *rows() {
|
|
494
556
|
// UNION: yield deduplicated rows from both sides
|
|
495
557
|
const seen = new Set()
|
|
496
558
|
for await (const row of left.rows()) {
|
|
@@ -518,7 +580,7 @@ function executeSetOperation(plan, context) {
|
|
|
518
580
|
return {
|
|
519
581
|
columns: left.columns,
|
|
520
582
|
maxRows: minBounds(left.maxRows, right.maxRows),
|
|
521
|
-
async *rows
|
|
583
|
+
async *rows() {
|
|
522
584
|
// Materialize right side keys
|
|
523
585
|
/** @type {Map<any, number>} */
|
|
524
586
|
const rightKeys = new Map()
|
|
@@ -560,7 +622,7 @@ function executeSetOperation(plan, context) {
|
|
|
560
622
|
return {
|
|
561
623
|
columns: left.columns,
|
|
562
624
|
maxRows: left.maxRows,
|
|
563
|
-
async *rows
|
|
625
|
+
async *rows() {
|
|
564
626
|
// Materialize right side keys
|
|
565
627
|
/** @type {Map<any, number>} */
|
|
566
628
|
const rightKeys = new Map()
|
package/src/execute/join.js
CHANGED
|
@@ -19,7 +19,7 @@ export function executeNestedLoopJoin(plan, 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
|
+
async *rows() {
|
|
23
23
|
const leftTable = plan.leftAlias
|
|
24
24
|
const rightTable = plan.rightAlias
|
|
25
25
|
|
|
@@ -97,7 +97,7 @@ export function executePositionalJoin(plan, context) {
|
|
|
97
97
|
columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
|
|
98
98
|
numRows,
|
|
99
99
|
maxRows: maxBounds(left.maxRows, right.maxRows),
|
|
100
|
-
async *rows
|
|
100
|
+
async *rows() {
|
|
101
101
|
const { signal } = context
|
|
102
102
|
const leftTable = plan.leftAlias
|
|
103
103
|
const rightTable = plan.rightAlias
|
|
@@ -143,7 +143,7 @@ export function executeHashJoin(plan, context) {
|
|
|
143
143
|
const right = executePlan({ plan: plan.right, context })
|
|
144
144
|
return {
|
|
145
145
|
columns: mergeColumnNames(left.columns, right.columns, plan.leftAlias, plan.rightAlias),
|
|
146
|
-
async *rows
|
|
146
|
+
async *rows() {
|
|
147
147
|
const leftTable = plan.leftAlias
|
|
148
148
|
const rightTable = plan.rightAlias
|
|
149
149
|
|
package/src/execute/sort.js
CHANGED
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
|
@@ -46,6 +46,9 @@ export interface ExecuteContext {
|
|
|
46
46
|
export interface AsyncRow {
|
|
47
47
|
columns: string[]
|
|
48
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>
|
|
49
52
|
}
|
|
50
53
|
export type AsyncCells = Record<string, AsyncCell>
|
|
51
54
|
export type AsyncCell = () => Promise<SqlPrimitive>
|
|
@@ -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' },
|