squirreling 0.16.14 → 0.16.15
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 +5 -5
- package/package.json +3 -3
- package/src/ast.d.ts +1 -1
- package/src/execute/accumulator.js +22 -2
- package/src/execute/execute.js +3 -0
- package/src/execute/streamingAggregate.js +21 -10
- package/src/expression/batch.js +6 -12
- package/src/expression/evaluate.js +12 -17
- package/src/expression/scalar.js +22 -2
- package/src/expression/strings.js +11 -0
- package/src/internalTypes.d.ts +1 -0
- package/src/parse/expression.js +12 -4
- package/src/types.d.ts +6 -3
- package/src/validation/functions.js +6 -2
package/README.md
CHANGED
|
@@ -147,7 +147,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
147
147
|
- `GROUP BY` and `HAVING` clauses, including `GROUP BY ALL`
|
|
148
148
|
- Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
|
|
149
149
|
- `SHOW TABLES` to list the available tables
|
|
150
|
-
- Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT
|
|
150
|
+
- Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS [NOT] NULL`, `IS [NOT] TRUE`, `IS [NOT] FALSE`, string concatenation `||`
|
|
151
151
|
- Subscript access: zero-based array indexing `col[0]`, struct field access `col['field']`, and chains like `col[0].field`
|
|
152
152
|
|
|
153
153
|
### Quoting
|
|
@@ -158,13 +158,13 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
158
158
|
|
|
159
159
|
### Functions
|
|
160
160
|
|
|
161
|
-
- Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MIN_BY`, `MAX_BY`, `ANY_VALUE`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
|
|
161
|
+
- Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MIN_BY`, `MAX_BY`, `ANY_VALUE`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`, `GROUP_CONCAT`
|
|
162
162
|
- Window: `ROW_NUMBER`, `LAG`, `LEAD`
|
|
163
|
-
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`
|
|
163
|
+
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`, `CHR`
|
|
164
164
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
165
165
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
166
|
-
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
|
|
167
|
-
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
|
|
166
|
+
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `NOW`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
|
|
167
|
+
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_EXTRACT_SCALAR`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
|
|
168
168
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_CONTAINS`, `ARRAY_SORT`, `ARRAY_APPEND`, `ARRAY_CONCAT`, `LEN`, `CARDINALITY`, `SIZE`
|
|
169
169
|
- Table functions: `UNNEST`, `EXPLODE`, `JSON_EACH`
|
|
170
170
|
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`, `REGEXP_LIKE`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.15",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@eslint/js": "10.0.1",
|
|
43
|
-
"@types/node": "26.6.
|
|
43
|
+
"@types/node": "26.6.6",
|
|
44
44
|
"@vitest/coverage-v8": "5.0.5",
|
|
45
45
|
"eslint": "10.13.0",
|
|
46
|
-
"eslint-plugin-jsdoc": "65.4.
|
|
46
|
+
"eslint-plugin-jsdoc": "65.4.2",
|
|
47
47
|
"globals": "17.13.0",
|
|
48
48
|
"typescript": "7.0.2",
|
|
49
49
|
"vitest": "5.0.5"
|
package/src/ast.d.ts
CHANGED
|
@@ -92,7 +92,7 @@ export interface IdentifierNode extends AstBase {
|
|
|
92
92
|
|
|
93
93
|
export interface UnaryNode extends AstBase {
|
|
94
94
|
type: 'unary'
|
|
95
|
-
op: 'NOT' | 'IS NULL' | 'IS NOT NULL' | '-'
|
|
95
|
+
op: 'NOT' | 'IS NULL' | 'IS NOT NULL' | 'IS TRUE' | 'IS NOT TRUE' | 'IS FALSE' | 'IS NOT FALSE' | '-'
|
|
96
96
|
argument: ExprNode
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -7,13 +7,15 @@ import { keyify } from './utils.js'
|
|
|
7
7
|
/**
|
|
8
8
|
* Incremental state for one streamable aggregate. Shared by the streaming
|
|
9
9
|
* aggregate executor and the scanColumn fast path so COUNT/COUNTIF/SUM/AVG/
|
|
10
|
-
* MIN/MAX fold semantics live in one place.
|
|
10
|
+
* MIN/MAX/MIN_BY/MAX_BY fold semantics live in one place.
|
|
11
11
|
*
|
|
12
12
|
* @typedef {{
|
|
13
13
|
* count: number,
|
|
14
14
|
* sum: number,
|
|
15
15
|
* min: SqlPrimitive,
|
|
16
16
|
* max: SqlPrimitive,
|
|
17
|
+
* best?: SqlPrimitive,
|
|
18
|
+
* bestKey?: SqlPrimitive,
|
|
17
19
|
* seen: Set<unknown> | null,
|
|
18
20
|
* }} Accumulator
|
|
19
21
|
*/
|
|
@@ -41,8 +43,9 @@ export function newAccumulator(funcName, distinct) {
|
|
|
41
43
|
* @param {string} funcName
|
|
42
44
|
* @param {Accumulator} acc
|
|
43
45
|
* @param {SqlPrimitive} value
|
|
46
|
+
* @param {SqlPrimitive} [key] - comparison key for MIN_BY/MAX_BY
|
|
44
47
|
*/
|
|
45
|
-
export function updateAccumulator(funcName, acc, value) {
|
|
48
|
+
export function updateAccumulator(funcName, acc, value, key) {
|
|
46
49
|
switch (funcName) {
|
|
47
50
|
case 'COUNT':
|
|
48
51
|
if (value == null) break
|
|
@@ -58,6 +61,19 @@ export function updateAccumulator(funcName, acc, value) {
|
|
|
58
61
|
case 'MAX':
|
|
59
62
|
if (value != null && (acc.max === null || value > acc.max)) acc.max = value
|
|
60
63
|
break
|
|
64
|
+
case 'MIN_BY':
|
|
65
|
+
case 'ARG_MIN':
|
|
66
|
+
case 'MAX_BY':
|
|
67
|
+
case 'ARG_MAX': {
|
|
68
|
+
if (value == null || key == null) break
|
|
69
|
+
const isMin = funcName === 'MIN_BY' || funcName === 'ARG_MIN'
|
|
70
|
+
// Match buffered aggregation: ignore null pairs and keep the first tie.
|
|
71
|
+
if (acc.bestKey == null || (isMin ? key < acc.bestKey : key > acc.bestKey)) {
|
|
72
|
+
acc.best = value
|
|
73
|
+
acc.bestKey = key
|
|
74
|
+
}
|
|
75
|
+
break
|
|
76
|
+
}
|
|
61
77
|
default: { // SUM, AVG
|
|
62
78
|
if (value == null) break
|
|
63
79
|
const num = Number(value)
|
|
@@ -84,6 +100,10 @@ export function finalizeAccumulator(funcName, acc) {
|
|
|
84
100
|
case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
|
|
85
101
|
case 'MIN': return acc.min
|
|
86
102
|
case 'MAX': return acc.max
|
|
103
|
+
case 'MIN_BY':
|
|
104
|
+
case 'ARG_MIN':
|
|
105
|
+
case 'MAX_BY':
|
|
106
|
+
case 'ARG_MAX': return acc.best ?? null
|
|
87
107
|
default: return null
|
|
88
108
|
}
|
|
89
109
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -98,6 +98,9 @@ export function executeStatement({ query, context, outerScope }) {
|
|
|
98
98
|
* @returns {QueryResults}
|
|
99
99
|
*/
|
|
100
100
|
export function executePlan({ plan, context }) {
|
|
101
|
+
if (context.currentTimestamp === undefined) {
|
|
102
|
+
context = { ...context, currentTimestamp: new Date().toISOString() }
|
|
103
|
+
}
|
|
101
104
|
if (plan.type === 'Scan') {
|
|
102
105
|
return executeScan(plan, context)
|
|
103
106
|
} else if (plan.type === 'SingleRow') {
|
|
@@ -24,7 +24,7 @@ const CHUNK_SIZE = 4000
|
|
|
24
24
|
// Aggregate functions whose state can be accumulated one row at a time with
|
|
25
25
|
// bounded memory. Aggregates outside this set (MEDIAN, ARRAY_AGG, STDDEV, ...)
|
|
26
26
|
// need the full value set, so their queries buffer rows instead.
|
|
27
|
-
const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX'])
|
|
27
|
+
const STREAMABLE_FUNCS = new Set(['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'MAX_BY', 'ARG_MIN', 'ARG_MAX'])
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Specs are keyed by aggregate node identity, so two aggregates that differ
|
|
@@ -178,6 +178,7 @@ export function planStreamingAggregates({ columns, having, orderBy, groupBy }, c
|
|
|
178
178
|
}
|
|
179
179
|
if (!STREAMABLE_FUNCS.has(funcName)) return false
|
|
180
180
|
const star = node.args[0]?.type === 'star'
|
|
181
|
+
if (star && node.args.length > 1) return false
|
|
181
182
|
if (lazy && !(star && !node.filter)) return false
|
|
182
183
|
if (!star && !node.args.every(arg => isScalarExpr(arg))) return false
|
|
183
184
|
if (node.filter && !isScalarExpr(node.filter)) return false
|
|
@@ -401,7 +402,7 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
|
|
|
401
402
|
rows: chunk,
|
|
402
403
|
signal: context.signal,
|
|
403
404
|
// One flat tuple per row: group key values, then per spec its FILTER
|
|
404
|
-
// result and its
|
|
405
|
+
// result and its arguments. Arguments are only evaluated for rows that
|
|
405
406
|
// pass the FILTER, matching the buffered path.
|
|
406
407
|
evaluate(row) {
|
|
407
408
|
const pending = groupBy.map(expr => evaluateExpr({ node: expr, row, context }))
|
|
@@ -410,10 +411,14 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
|
|
|
410
411
|
const passes = evaluateExpr({ node: node.filter, row, context })
|
|
411
412
|
pending.push(passes)
|
|
412
413
|
if (!star) {
|
|
413
|
-
|
|
414
|
+
for (const argument of node.args) {
|
|
415
|
+
pending.push(passes.then(pass => pass ? evaluateExpr({ node: argument, row, context }) : null))
|
|
416
|
+
}
|
|
414
417
|
}
|
|
415
418
|
} else if (!star) {
|
|
416
|
-
|
|
419
|
+
for (const argument of node.args) {
|
|
420
|
+
pending.push(evaluateExpr({ node: argument, row, context }))
|
|
421
|
+
}
|
|
417
422
|
}
|
|
418
423
|
}
|
|
419
424
|
return Promise.all(pending)
|
|
@@ -431,11 +436,12 @@ function accumulateChunk({ chunk, groupBy, specs, groups, needsRow, context }) {
|
|
|
431
436
|
const spec = specs[s]
|
|
432
437
|
const passes = spec.node.filter ? values[slot++] : true
|
|
433
438
|
const arg = spec.star ? null : values[slot++]
|
|
439
|
+
const comparison = spec.node.args.length > 1 ? values[slot++] : undefined
|
|
434
440
|
if (!passes) continue
|
|
435
441
|
if (spec.star && spec.funcName === 'COUNT') {
|
|
436
442
|
group.accumulators[s].count++
|
|
437
443
|
} else {
|
|
438
|
-
updateAccumulator(spec.funcName, group.accumulators[s], arg)
|
|
444
|
+
updateAccumulator(spec.funcName, group.accumulators[s], arg, comparison)
|
|
439
445
|
}
|
|
440
446
|
}
|
|
441
447
|
},
|
|
@@ -470,16 +476,18 @@ function compileBatchAggregateInputs(groupBy, specs, columns, context) {
|
|
|
470
476
|
for (const spec of specs) {
|
|
471
477
|
if (spec.node.filter && !spec.star) return undefined
|
|
472
478
|
if (spec.node.filter && referencesRowScope(spec.node.filter, columns, context)) return undefined
|
|
473
|
-
if (!spec.star &&
|
|
479
|
+
if (!spec.star && spec.node.args.some(argument => referencesRowScope(argument, columns, context))) return undefined
|
|
474
480
|
const filter = spec.node.filter
|
|
475
481
|
? compileBatchExpression(spec.node.filter, columns)
|
|
476
482
|
: undefined
|
|
477
483
|
const argument = spec.star
|
|
478
484
|
? undefined
|
|
479
485
|
: compileBatchExpression(spec.node.args[0], columns)
|
|
480
|
-
|
|
486
|
+
const comparison = spec.node.args[1] && compileBatchExpression(spec.node.args[1], columns)
|
|
487
|
+
if (spec.node.filter && !filter || !spec.star && !argument || spec.node.args[1] && !comparison) return undefined
|
|
481
488
|
filters.push(filter)
|
|
482
489
|
args.push(argument)
|
|
490
|
+
if (spec.node.args[1]) args.push(comparison)
|
|
483
491
|
}
|
|
484
492
|
return {
|
|
485
493
|
keys,
|
|
@@ -563,15 +571,18 @@ async function accumulateBatch({ batch, inputs, specs, groups, context, rowOffse
|
|
|
563
571
|
}
|
|
564
572
|
groups.set(key, group)
|
|
565
573
|
}
|
|
574
|
+
let argumentIndex = 0
|
|
566
575
|
for (let specIndex = 0; specIndex < specs.length; specIndex++) {
|
|
576
|
+
const spec = specs[specIndex]
|
|
577
|
+
const argument = args[argumentIndex++]
|
|
578
|
+
const comparison = spec.node.args[1] ? args[argumentIndex++] : undefined
|
|
567
579
|
const filter = filters[specIndex]
|
|
568
580
|
if (filter && !valueAt(filter, rowIndex)) continue
|
|
569
|
-
const spec = specs[specIndex]
|
|
570
581
|
if (spec.star && spec.funcName === 'COUNT') {
|
|
571
582
|
group.accumulators[specIndex].count++
|
|
572
583
|
} else {
|
|
573
|
-
|
|
574
|
-
|
|
584
|
+
updateAccumulator(spec.funcName, group.accumulators[specIndex],
|
|
585
|
+
argument ? valueAt(argument, rowIndex) : null, comparison ? valueAt(comparison, rowIndex) : undefined)
|
|
575
586
|
}
|
|
576
587
|
}
|
|
577
588
|
}
|
package/src/expression/batch.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isStringFunc } from '../validation/functions.js'
|
|
|
5
5
|
import { ColumnNotFoundError } from '../validation/tables.js'
|
|
6
6
|
import { applyBinaryOp } from './binary.js'
|
|
7
7
|
import { evaluateRegexpLike } from './regexp.js'
|
|
8
|
-
import { applyCast, evaluateJsonExtract } from './scalar.js'
|
|
8
|
+
import { applyCast, applyUnaryOp, evaluateJsonExtract } from './scalar.js'
|
|
9
9
|
import { evaluateStringFunc } from './strings.js'
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -52,10 +52,7 @@ function compileEvaluator(node, columns) {
|
|
|
52
52
|
const vector = await argument.evaluate(context)
|
|
53
53
|
return evaluateValues(context, function unaryValue(rowIndex) {
|
|
54
54
|
const value = valueAt(vector, rowIndex)
|
|
55
|
-
|
|
56
|
-
if (node.op === 'NOT') return value == null ? null : !value
|
|
57
|
-
if (node.op === 'IS NULL') return value == null
|
|
58
|
-
return value != null
|
|
55
|
+
return applyUnaryOp(node.op, value)
|
|
59
56
|
})
|
|
60
57
|
},
|
|
61
58
|
}
|
|
@@ -184,10 +181,7 @@ function compileValueKernel(node, state) {
|
|
|
184
181
|
if (!argument) return undefined
|
|
185
182
|
return function unaryValue(vectors, rowIndex, streamRowIndex) {
|
|
186
183
|
const value = argument(vectors, rowIndex, streamRowIndex)
|
|
187
|
-
|
|
188
|
-
if (node.op === 'NOT') return value == null ? null : !value
|
|
189
|
-
if (node.op === 'IS NULL') return value == null
|
|
190
|
-
return value != null
|
|
184
|
+
return applyUnaryOp(node.op, value)
|
|
191
185
|
}
|
|
192
186
|
}
|
|
193
187
|
|
|
@@ -278,7 +272,7 @@ function compileFunctionKernel(node, state) {
|
|
|
278
272
|
return sqlEquals(first, second) ? null : first
|
|
279
273
|
}
|
|
280
274
|
}
|
|
281
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
275
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
|
|
282
276
|
return function jsonValue(vectors, rowIndex, streamRowIndex) {
|
|
283
277
|
const args = arguments_.map(function argumentValue(argument) {
|
|
284
278
|
return argument(vectors, rowIndex, streamRowIndex)
|
|
@@ -327,7 +321,7 @@ function compileFunctionEvaluator(node, columns) {
|
|
|
327
321
|
}
|
|
328
322
|
}
|
|
329
323
|
if (funcName !== 'NULLIF' && funcName !== 'JSON_VALUE' && funcName !== 'JSON_QUERY' &&
|
|
330
|
-
funcName !== 'JSON_EXTRACT' && funcName !== 'JSON_EXTRACT_STRING' && funcName !== 'REGEXP_LIKE' &&
|
|
324
|
+
funcName !== 'JSON_EXTRACT' && funcName !== 'JSON_EXTRACT_STRING' && funcName !== 'JSON_EXTRACT_SCALAR' && funcName !== 'REGEXP_LIKE' &&
|
|
331
325
|
!isStringFunc(funcName)) return undefined
|
|
332
326
|
const regexpCache = funcName === 'REGEXP_LIKE' && node.args[1]?.type === 'literal' ? {} : undefined
|
|
333
327
|
return {
|
|
@@ -338,7 +332,7 @@ function compileFunctionEvaluator(node, columns) {
|
|
|
338
332
|
return evaluateValues(context, function functionValue(rowIndex, streamRowIndex) {
|
|
339
333
|
const args = vectors.map(function argumentValue(vector) { return valueAt(vector, rowIndex) })
|
|
340
334
|
if (funcName === 'NULLIF') return sqlEquals(args[0], args[1]) ? null : args[0]
|
|
341
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
335
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
|
|
342
336
|
return evaluateJsonExtract({ funcName, node, args, rowIndex: streamRowIndex + 1 })
|
|
343
337
|
}
|
|
344
338
|
if (funcName === 'REGEXP_LIKE') {
|
|
@@ -10,7 +10,7 @@ import { applyBinaryOp } from './binary.js'
|
|
|
10
10
|
import { applyIntervalToDate, dateDiff, dateTrunc, extractField } from './date.js'
|
|
11
11
|
import { evaluateMathFunc } from './math.js'
|
|
12
12
|
import { evaluateRegexpFunc } from './regexp.js'
|
|
13
|
-
import { applyCast, evaluateJsonExtract } from './scalar.js'
|
|
13
|
+
import { applyCast, applyUnaryOp, evaluateJsonExtract } from './scalar.js'
|
|
14
14
|
import { evaluateSpatialFunc } from '../spatial/spatial.js'
|
|
15
15
|
import { evaluateStringFunc } from './strings.js'
|
|
16
16
|
|
|
@@ -171,15 +171,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
171
171
|
// Unary operators
|
|
172
172
|
if (node.type === 'unary') {
|
|
173
173
|
const val = await evaluateExpr({ node: node.argument, row, rowIndex, rows, context })
|
|
174
|
-
|
|
175
|
-
if (val == null) return null
|
|
176
|
-
return -val
|
|
177
|
-
}
|
|
178
|
-
// NOT UNKNOWN is UNKNOWN: JS ! would flip null to true and return rows
|
|
179
|
-
// SQL excludes, e.g. WHERE NOT (nullable < 300)
|
|
180
|
-
if (node.op === 'NOT') return val == null ? null : !val
|
|
181
|
-
if (node.op === 'IS NULL') return val == null
|
|
182
|
-
if (node.op === 'IS NOT NULL') return val != null
|
|
174
|
+
return applyUnaryOp(node.op, val)
|
|
183
175
|
}
|
|
184
176
|
|
|
185
177
|
// Binary operators
|
|
@@ -411,9 +403,12 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
411
403
|
}
|
|
412
404
|
}
|
|
413
405
|
|
|
414
|
-
if (funcName === 'STRING_AGG') {
|
|
406
|
+
if (funcName === 'STRING_AGG' || funcName === 'GROUP_CONCAT') {
|
|
415
407
|
const separatorNode = node.args[1]
|
|
416
|
-
const
|
|
408
|
+
const separatorValue = separatorNode
|
|
409
|
+
? await evaluateExpr({ node: separatorNode, row: filteredRows[0] ?? { columns: [], cells: {} }, context })
|
|
410
|
+
: ','
|
|
411
|
+
const separator = funcName === 'GROUP_CONCAT' && separatorValue == null ? '' : String(separatorValue)
|
|
417
412
|
/** @type {string[]} */
|
|
418
413
|
const values = []
|
|
419
414
|
const allValues = await evaluateAll(argNode, filteredRows, context)
|
|
@@ -506,15 +501,15 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
506
501
|
}
|
|
507
502
|
|
|
508
503
|
if (funcName === 'CURRENT_DATE') {
|
|
509
|
-
return new Date().toISOString().split('T')[0]
|
|
504
|
+
return (context.currentTimestamp ?? new Date().toISOString()).split('T')[0]
|
|
510
505
|
}
|
|
511
506
|
|
|
512
507
|
if (funcName === 'CURRENT_TIME') {
|
|
513
|
-
return new Date().toISOString().split('T')[1].replace('Z', '')
|
|
508
|
+
return (context.currentTimestamp ?? new Date().toISOString()).split('T')[1].replace('Z', '')
|
|
514
509
|
}
|
|
515
510
|
|
|
516
|
-
if (funcName === 'CURRENT_TIMESTAMP') {
|
|
517
|
-
return new Date().toISOString()
|
|
511
|
+
if (funcName === 'CURRENT_TIMESTAMP' || funcName === 'NOW') {
|
|
512
|
+
return context.currentTimestamp ?? new Date().toISOString()
|
|
518
513
|
}
|
|
519
514
|
|
|
520
515
|
if (funcName === 'JSON_OBJECT') {
|
|
@@ -676,7 +671,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
676
671
|
})
|
|
677
672
|
}
|
|
678
673
|
|
|
679
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
674
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING' || funcName === 'JSON_EXTRACT_SCALAR') {
|
|
680
675
|
return evaluateJsonExtract({ funcName, node, args, rowIndex })
|
|
681
676
|
}
|
|
682
677
|
|
package/src/expression/scalar.js
CHANGED
|
@@ -3,7 +3,7 @@ import { ArgValueError, ExecutionError } from '../validation/executionErrors.js'
|
|
|
3
3
|
import { toDate } from './date.js'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @import { CastNode, FunctionNode, SqlPrimitive } from '../types.js'
|
|
6
|
+
* @import { CastNode, FunctionNode, SqlPrimitive, UnaryNode } from '../types.js'
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -72,7 +72,7 @@ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
-
if (typeof json !== 'object' || json instanceof Date) {
|
|
75
|
+
if (funcName !== 'JSON_EXTRACT_SCALAR' && typeof json !== 'object' || json instanceof Date) {
|
|
76
76
|
throw new ArgValueError({
|
|
77
77
|
...node,
|
|
78
78
|
message: `first argument must be JSON string or object, got ${typeof json}`,
|
|
@@ -98,8 +98,28 @@ export function evaluateJsonExtract({ funcName, node, args, rowIndex }) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
if (current == null) return null
|
|
101
|
+
if (funcName === 'JSON_EXTRACT_SCALAR') {
|
|
102
|
+
return typeof current === 'object' ? null : String(current)
|
|
103
|
+
}
|
|
101
104
|
if (funcName === 'JSON_EXTRACT_STRING') {
|
|
102
105
|
return typeof current === 'object' ? JSON.stringify(current) : String(current)
|
|
103
106
|
}
|
|
104
107
|
return current
|
|
105
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Applies unary predicates identically in row and batch execution.
|
|
112
|
+
* @param {UnaryNode['op']} op
|
|
113
|
+
* @param {SqlPrimitive} value
|
|
114
|
+
* @returns {SqlPrimitive}
|
|
115
|
+
*/
|
|
116
|
+
export function applyUnaryOp(op, value) {
|
|
117
|
+
if (op === '-') return value == null ? null : -value
|
|
118
|
+
if (op === 'NOT') return value == null ? null : !value
|
|
119
|
+
if (op === 'IS NULL') return value == null
|
|
120
|
+
if (op === 'IS NOT NULL') return value != null
|
|
121
|
+
if (op === 'IS TRUE') return value === true
|
|
122
|
+
if (op === 'IS NOT TRUE') return value !== true
|
|
123
|
+
if (op === 'IS FALSE') return value === false
|
|
124
|
+
return value !== false
|
|
125
|
+
}
|
|
@@ -35,6 +35,17 @@ export function evaluateStringFunc({ funcName, node, args, rowIndex }) {
|
|
|
35
35
|
const [val] = args
|
|
36
36
|
if (val == null) return null
|
|
37
37
|
|
|
38
|
+
if (funcName === 'CHR') {
|
|
39
|
+
const codePoint = typeof val === 'number' || typeof val === 'bigint' ? Number(val) : NaN
|
|
40
|
+
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff ||
|
|
41
|
+
codePoint >= 0xd800 && codePoint <= 0xdfff) {
|
|
42
|
+
throw new ArgValueError({
|
|
43
|
+
...node, message: 'expected a Unicode scalar value (0..0x10ffff, excluding surrogates)', rowIndex,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return String.fromCodePoint(codePoint)
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
if (funcName === 'LENGTH') {
|
|
39
50
|
if (typeof val === 'string' || Array.isArray(val)) return val.length
|
|
40
51
|
throw new ArgValueError({
|
package/src/internalTypes.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface CompileState {
|
|
|
23
23
|
export interface BatchAggregateInputs {
|
|
24
24
|
keys: CompiledBatchExpression[]
|
|
25
25
|
filters: (CompiledBatchExpression | undefined)[]
|
|
26
|
+
// One value slot per aggregate, followed by its key for MIN_BY/MAX_BY.
|
|
26
27
|
args: (CompiledBatchExpression | undefined)[]
|
|
27
28
|
}
|
|
28
29
|
|
package/src/parse/expression.js
CHANGED
|
@@ -5,7 +5,7 @@ import { parseStatement } from './parse.js'
|
|
|
5
5
|
import { consume, current, expect, match } from './state.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @import { ExprNode, ParserState } from '../types.js'
|
|
8
|
+
* @import { ExprNode, ParserState, UnaryNode } from '../types.js'
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
// Precedence (lowest to highest):
|
|
@@ -91,10 +91,18 @@ function parseComparison(state) {
|
|
|
91
91
|
const left = parseConcat(state)
|
|
92
92
|
const { positionStart } = left
|
|
93
93
|
|
|
94
|
-
// IS [NOT] NULL
|
|
94
|
+
// IS [NOT] NULL/TRUE/FALSE
|
|
95
95
|
if (match(state, 'keyword', 'IS')) {
|
|
96
|
-
const
|
|
97
|
-
|
|
96
|
+
const not = match(state, 'keyword', 'NOT')
|
|
97
|
+
const target = current(state)
|
|
98
|
+
if (target.type !== 'keyword' || !['NULL', 'TRUE', 'FALSE'].includes(target.value)) {
|
|
99
|
+
throw new SyntaxError({ expected: 'NULL, TRUE, or FALSE', after: not ? 'NOT' : 'IS', ...target })
|
|
100
|
+
}
|
|
101
|
+
consume(state)
|
|
102
|
+
/** @type {UnaryNode['op']} */
|
|
103
|
+
const op = target.value === 'TRUE' ? not ? 'IS NOT TRUE' : 'IS TRUE'
|
|
104
|
+
: target.value === 'FALSE' ? not ? 'IS NOT FALSE' : 'IS FALSE'
|
|
105
|
+
: not ? 'IS NOT NULL' : 'IS NULL'
|
|
98
106
|
return {
|
|
99
107
|
type: 'unary',
|
|
100
108
|
op,
|
package/src/types.d.ts
CHANGED
|
@@ -199,8 +199,6 @@ export interface PlanSqlOptions {
|
|
|
199
199
|
|
|
200
200
|
// executePlan(plan, context)
|
|
201
201
|
export interface ExecuteContext {
|
|
202
|
-
// Delay opening join input scans until their rows are consumed.
|
|
203
|
-
deferScans?: boolean
|
|
204
202
|
tables: Record<string, AsyncDataSource>
|
|
205
203
|
functions?: Record<string, UserDefinedFunction>
|
|
206
204
|
signal?: AbortSignal
|
|
@@ -214,6 +212,10 @@ export interface ExecuteContext {
|
|
|
214
212
|
// CTE references in subqueries re-planned during execution
|
|
215
213
|
ctePlans?: Map<string, QueryPlan>
|
|
216
214
|
cteColumns?: Map<string, string[]>
|
|
215
|
+
// UTC timestamp shared by current-time functions throughout one execution.
|
|
216
|
+
currentTimestamp?: string
|
|
217
|
+
// Delay opening join input scans until their rows are consumed.
|
|
218
|
+
deferScans?: boolean
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
// AsyncRow represents a row with async cell values
|
|
@@ -314,7 +316,7 @@ export interface UserDefinedFunction {
|
|
|
314
316
|
arguments: FunctionSignature
|
|
315
317
|
}
|
|
316
318
|
|
|
317
|
-
export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'MIN_BY' | 'ARG_MIN' | 'MAX_BY' | 'ARG_MAX' | 'ANY_VALUE' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
|
|
319
|
+
export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'MIN_BY' | 'ARG_MIN' | 'MAX_BY' | 'ARG_MAX' | 'ANY_VALUE' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG' | 'GROUP_CONCAT'
|
|
318
320
|
|
|
319
321
|
export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES' | 'REGEXP_LIKE'
|
|
320
322
|
|
|
@@ -347,6 +349,7 @@ export type MathFunc =
|
|
|
347
349
|
| 'RANDOM'
|
|
348
350
|
|
|
349
351
|
export type StringFunc =
|
|
352
|
+
| 'CHR'
|
|
350
353
|
| 'UPPER'
|
|
351
354
|
| 'LOWER'
|
|
352
355
|
| 'CONCAT'
|
|
@@ -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', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'ARG_MIN', 'MAX_BY', 'ARG_MAX', 'ANY_VALUE', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
|
|
14
|
+
return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'MIN_BY', 'ARG_MIN', 'MAX_BY', 'ARG_MAX', 'ANY_VALUE', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG', 'GROUP_CONCAT'].includes(name)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -96,7 +96,7 @@ export function isCastType(name) {
|
|
|
96
96
|
export function isStringFunc(name) {
|
|
97
97
|
return [
|
|
98
98
|
'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'OCTET_LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM',
|
|
99
|
-
'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT',
|
|
99
|
+
'REPLACE', 'LEFT', 'RIGHT', 'INSTR', 'POSITION', 'STRPOS', 'SPLIT_PART', 'STRING_SPLIT', 'CHR',
|
|
100
100
|
].includes(name)
|
|
101
101
|
}
|
|
102
102
|
|
|
@@ -114,6 +114,7 @@ export function isBinaryOp(op) {
|
|
|
114
114
|
*/
|
|
115
115
|
export const FUNCTION_SIGNATURES = {
|
|
116
116
|
// String functions
|
|
117
|
+
CHR: { min: 1, max: 1, signature: 'code_point' },
|
|
117
118
|
UPPER: { min: 1, max: 1, signature: 'string' },
|
|
118
119
|
LOWER: { min: 1, max: 1, signature: 'string' },
|
|
119
120
|
LENGTH: { min: 1, max: 1, signature: 'string' },
|
|
@@ -137,6 +138,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
137
138
|
REGEXP_LIKE: { min: 2, max: 2, signature: 'string, pattern' },
|
|
138
139
|
|
|
139
140
|
// Date/time functions
|
|
141
|
+
NOW: { min: 0, max: 0, signature: '' },
|
|
140
142
|
RANDOM: { min: 0, max: 0, signature: '' },
|
|
141
143
|
RAND: { min: 0, max: 0, signature: '' },
|
|
142
144
|
CURRENT_DATE: { min: 0, max: 0, signature: '' },
|
|
@@ -176,6 +178,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
176
178
|
PI: { min: 0, max: 0, signature: '' },
|
|
177
179
|
|
|
178
180
|
// JSON functions
|
|
181
|
+
JSON_EXTRACT_SCALAR: { min: 2, max: 2, signature: 'expression, path' },
|
|
179
182
|
JSON_VALUE: { min: 2, max: 2, signature: 'expression, path' },
|
|
180
183
|
JSON_QUERY: { min: 2, max: 2, signature: 'expression, path' },
|
|
181
184
|
JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
|
|
@@ -217,6 +220,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
217
220
|
LEAST: { min: 1, signature: 'value1[, value2, ...]' },
|
|
218
221
|
|
|
219
222
|
// Aggregate functions
|
|
223
|
+
GROUP_CONCAT: { min: 1, max: 2, signature: 'expression[, separator]' },
|
|
220
224
|
COUNT: { min: 1, max: 1, signature: 'expression' },
|
|
221
225
|
COUNTIF: { min: 1, max: 1, signature: 'condition' },
|
|
222
226
|
SUM: { min: 1, max: 1, signature: 'expression' },
|