squirreling 0.15.0 → 0.15.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 +3 -3
- package/src/ast.d.ts +2 -0
- package/src/execute/streamingAggregate.js +3 -0
- package/src/expression/evaluate.js +53 -14
- package/src/expression/regexp.js +1 -1
- package/src/parse/primary.js +3 -2
- package/src/types.d.ts +2 -2
- package/src/validation/functions.js +9 -2
package/README.md
CHANGED
|
@@ -146,7 +146,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
146
146
|
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `POSITIONAL JOIN`, `LATERAL VIEW [OUTER] EXPLODE(...)`, with `ON` or `USING (col, ...)` conditions
|
|
147
147
|
- `GROUP BY` and `HAVING` clauses
|
|
148
148
|
- Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
|
|
149
|
-
- Expressions: `CASE`, `CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`, string concatenation `||`
|
|
149
|
+
- Expressions: `CASE`, `CAST`, `TRY_CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`, string concatenation `||`
|
|
150
150
|
- Subscript access: zero-based array indexing `col[0]`, struct field access `col['field']`, and chains like `col[0].field`
|
|
151
151
|
|
|
152
152
|
### Quoting
|
|
@@ -157,16 +157,16 @@ Squirreling mostly follows the SQL standard. The following features are supporte
|
|
|
157
157
|
|
|
158
158
|
### Functions
|
|
159
159
|
|
|
160
|
-
- Aggregate: `COUNT`, `COUNTIF`, `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, `PERCENTILE_CONT`, `APPROX_QUANTILE`, `STDDEV_POP`, `STDDEV_SAMP`, `ARRAY_AGG`, `JSON_ARRAYAGG`, `STRING_AGG`
|
|
160
|
+
- 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
161
|
- Window: `ROW_NUMBER`, `LAG`, `LEAD`
|
|
162
162
|
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `OCTET_LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`, `POSITION`, `STRPOS`, `SPLIT_PART`, `STRING_SPLIT`
|
|
163
163
|
- Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
164
164
|
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
165
165
|
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
|
|
166
|
-
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
|
|
166
|
+
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_EXTRACT_STRING`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
|
|
167
167
|
- Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_CONTAINS`, `ARRAY_SORT`, `ARRAY_APPEND`, `ARRAY_CONCAT`, `LEN`, `CARDINALITY`, `SIZE`
|
|
168
168
|
- Table functions: `UNNEST`, `EXPLODE`, `JSON_EACH`
|
|
169
|
-
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
|
|
169
|
+
- Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`, `REGEXP_LIKE`
|
|
170
170
|
- 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`
|
|
171
171
|
- Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
|
|
172
172
|
- User-defined functions (UDFs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"description": "Squirreling Async SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"test": "vitest run"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@types/node": "26.
|
|
42
|
+
"@types/node": "26.2.0",
|
|
43
43
|
"@vitest/coverage-v8": "4.1.10",
|
|
44
44
|
"eslint": "9.39.4",
|
|
45
|
-
"eslint-plugin-jsdoc": "
|
|
45
|
+
"eslint-plugin-jsdoc": "64.1.0",
|
|
46
46
|
"typescript": "7.0.2",
|
|
47
47
|
"vitest": "4.1.10"
|
|
48
48
|
}
|
package/src/ast.d.ts
CHANGED
|
@@ -120,6 +120,8 @@ export interface CastNode extends AstBase {
|
|
|
120
120
|
type: 'cast'
|
|
121
121
|
expr: ExprNode
|
|
122
122
|
toType: CastType
|
|
123
|
+
/** TRY_CAST returns null instead of throwing when the value cannot be cast */
|
|
124
|
+
tryCast?: boolean
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export interface InSubqueryNode extends AstBase {
|
|
@@ -166,6 +166,9 @@ export function planStreamingAggregates({ columns, having, orderBy, groupBy }, c
|
|
|
166
166
|
case 'function': {
|
|
167
167
|
const funcName = node.funcName.toUpperCase()
|
|
168
168
|
if (!isAggregateFunc(funcName)) {
|
|
169
|
+
if (funcName === 'COALESCE') {
|
|
170
|
+
return node.args.every((arg, i) => walk(arg, lazy || i > 0))
|
|
171
|
+
}
|
|
169
172
|
return node.args.every(arg => walk(arg, lazy))
|
|
170
173
|
}
|
|
171
174
|
if (!STREAMABLE_FUNCS.has(funcName)) return false
|
|
@@ -289,6 +289,37 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
289
289
|
if (funcName === 'MAX') return max
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
if (funcName === 'MIN_BY' || funcName === 'ARG_MIN' || funcName === 'MAX_BY' || funcName === 'ARG_MAX') {
|
|
293
|
+
// Returns the value at the row with the smallest (or largest) key.
|
|
294
|
+
// Rows with a null value or key are ignored, and ties keep the first row.
|
|
295
|
+
const isMin = funcName === 'MIN_BY' || funcName === 'ARG_MIN'
|
|
296
|
+
const values = await evaluateAll(argNode, filteredRows, context)
|
|
297
|
+
const keys = await evaluateAll(node.args[1], filteredRows, context)
|
|
298
|
+
/** @type {SqlPrimitive} */
|
|
299
|
+
let best = null
|
|
300
|
+
/** @type {SqlPrimitive} */
|
|
301
|
+
let bestKey = null
|
|
302
|
+
for (let i = 0; i < values.length; i++) {
|
|
303
|
+
const value = values[i]
|
|
304
|
+
const key = keys[i]
|
|
305
|
+
if (value == null || key == null) continue
|
|
306
|
+
if (bestKey === null || (isMin ? key < bestKey : key > bestKey)) {
|
|
307
|
+
best = value
|
|
308
|
+
bestKey = key
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return best
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (funcName === 'ANY_VALUE') {
|
|
315
|
+
// Returns an arbitrary non-null value from the group; we pick the first
|
|
316
|
+
const values = await evaluateAll(argNode, filteredRows, context)
|
|
317
|
+
for (const v of values) {
|
|
318
|
+
if (v != null) return v
|
|
319
|
+
}
|
|
320
|
+
return null
|
|
321
|
+
}
|
|
322
|
+
|
|
292
323
|
if (funcName === 'STDDEV_SAMP' || funcName === 'STDDEV_POP') {
|
|
293
324
|
const rawValues = await evaluateAll(argNode, filteredRows, context)
|
|
294
325
|
let sum = 0
|
|
@@ -394,6 +425,18 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
394
425
|
}
|
|
395
426
|
}
|
|
396
427
|
|
|
428
|
+
// COALESCE must be handled before the eager arg evaluation below, or it
|
|
429
|
+
// evaluates every argument once eagerly and the arguments up to the first
|
|
430
|
+
// non-null value a second time. Nested calls compound that duplication.
|
|
431
|
+
if (funcName === 'COALESCE') {
|
|
432
|
+
// Short-circuit: evaluate args one at a time, return first non-null
|
|
433
|
+
for (const arg of node.args) {
|
|
434
|
+
const val = await evaluateExpr({ node: arg, row, rowIndex, rows, context })
|
|
435
|
+
if (val != null) return val
|
|
436
|
+
}
|
|
437
|
+
return null
|
|
438
|
+
}
|
|
439
|
+
|
|
397
440
|
/** @type {SqlPrimitive[]} */
|
|
398
441
|
const args = node.args.length === 1
|
|
399
442
|
? [await evaluateExpr({ node: node.args[0], row, rowIndex, rows, context })]
|
|
@@ -415,20 +458,9 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
415
458
|
return evaluateSpatialFunc({ funcName, args })
|
|
416
459
|
}
|
|
417
460
|
|
|
418
|
-
if (funcName === 'COALESCE') {
|
|
419
|
-
// Short-circuit: evaluate args one at a time, return first non-null
|
|
420
|
-
for (const arg of node.args) {
|
|
421
|
-
const val = await evaluateExpr({ node: arg, row, rowIndex, rows, context })
|
|
422
|
-
if (val != null) return val
|
|
423
|
-
}
|
|
424
|
-
return null
|
|
425
|
-
}
|
|
426
|
-
|
|
427
461
|
if (funcName === 'NULLIF') {
|
|
428
462
|
// NULLIF(a, b) returns null if a = b, otherwise returns a
|
|
429
|
-
|
|
430
|
-
const val1 = await evaluateExpr({ node: node.args[0], row, rowIndex, rows, context })
|
|
431
|
-
return val1 == await val2 ? null : val1
|
|
463
|
+
return args[0] == args[1] ? null : args[0]
|
|
432
464
|
}
|
|
433
465
|
|
|
434
466
|
if (funcName === 'GREATEST' || funcName === 'LEAST') {
|
|
@@ -632,7 +664,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
632
664
|
})
|
|
633
665
|
}
|
|
634
666
|
|
|
635
|
-
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT') {
|
|
667
|
+
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
|
|
636
668
|
let jsonArg = args[0]
|
|
637
669
|
const pathArg = args[1]
|
|
638
670
|
if (jsonArg == null || pathArg == null) return null
|
|
@@ -681,6 +713,11 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
681
713
|
}
|
|
682
714
|
|
|
683
715
|
if (current == null) return null
|
|
716
|
+
// JSON_EXTRACT_STRING returns text: unquoted scalars, JSON text for objects and arrays
|
|
717
|
+
if (funcName === 'JSON_EXTRACT_STRING') {
|
|
718
|
+
if (typeof current === 'object') return JSON.stringify(current)
|
|
719
|
+
return String(current)
|
|
720
|
+
}
|
|
684
721
|
return current
|
|
685
722
|
}
|
|
686
723
|
|
|
@@ -706,6 +743,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
706
743
|
}
|
|
707
744
|
// Can only cast primitives (and Dates) to other primitive types
|
|
708
745
|
if (typeof val === 'object' && !(val instanceof Date)) {
|
|
746
|
+
if (node.tryCast) return null
|
|
709
747
|
throw new ExecutionError({ message: `Cannot CAST object to ${toType}`, rowIndex, ...node })
|
|
710
748
|
}
|
|
711
749
|
if (toType === 'INTEGER' || toType === 'INT') {
|
|
@@ -716,7 +754,8 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
|
|
|
716
754
|
if (toType === 'BIGINT') {
|
|
717
755
|
if (typeof val === 'bigint') return val
|
|
718
756
|
const num = Number(val)
|
|
719
|
-
|
|
757
|
+
// NaN and Infinity have no bigint representation
|
|
758
|
+
if (!isFinite(num)) return null
|
|
720
759
|
return BigInt(Math.trunc(num))
|
|
721
760
|
}
|
|
722
761
|
if (toType === 'FLOAT' || toType === 'REAL' || toType === 'DOUBLE') {
|
package/src/expression/regexp.js
CHANGED
|
@@ -78,7 +78,7 @@ export function evaluateRegexpFunc({ funcName, node, args, rowIndex }) {
|
|
|
78
78
|
return null
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
if (funcName === 'REGEXP_MATCHES') {
|
|
81
|
+
if (funcName === 'REGEXP_MATCHES' || funcName === 'REGEXP_LIKE') {
|
|
82
82
|
const str = args[0]
|
|
83
83
|
const pattern = args[1]
|
|
84
84
|
if (str == null || pattern == null) return null
|
package/src/parse/primary.js
CHANGED
|
@@ -125,8 +125,8 @@ function parsePrimaryBase(state) {
|
|
|
125
125
|
const next = peekToken(state, 1)
|
|
126
126
|
const funcNameUpper = tok.value.toUpperCase()
|
|
127
127
|
|
|
128
|
-
// CAST(expr AS type)
|
|
129
|
-
if (funcNameUpper === 'CAST' && next.type === 'paren' && next.value === '(') {
|
|
128
|
+
// CAST(expr AS type) and TRY_CAST(expr AS type)
|
|
129
|
+
if ((funcNameUpper === 'CAST' || funcNameUpper === 'TRY_CAST') && next.type === 'paren' && next.value === '(') {
|
|
130
130
|
consume(state) // CAST
|
|
131
131
|
consume(state) // '('
|
|
132
132
|
const expr = parseExpression(state)
|
|
@@ -145,6 +145,7 @@ function parsePrimaryBase(state) {
|
|
|
145
145
|
type: 'cast',
|
|
146
146
|
expr,
|
|
147
147
|
toType,
|
|
148
|
+
tryCast: funcNameUpper === 'TRY_CAST' ? true : undefined,
|
|
148
149
|
positionStart,
|
|
149
150
|
positionEnd: state.lastPos,
|
|
150
151
|
}
|
package/src/types.d.ts
CHANGED
|
@@ -137,9 +137,9 @@ export interface UserDefinedFunction {
|
|
|
137
137
|
arguments: FunctionSignature
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
|
|
140
|
+
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'
|
|
141
141
|
|
|
142
|
-
export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES'
|
|
142
|
+
export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES' | 'REGEXP_LIKE'
|
|
143
143
|
|
|
144
144
|
export type MathFunc =
|
|
145
145
|
| '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', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', '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'].includes(name)
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -39,7 +39,7 @@ export function isWindowFunc(name) {
|
|
|
39
39
|
* @returns {name is RegExpFunction}
|
|
40
40
|
*/
|
|
41
41
|
export function isRegexpFunc(name) {
|
|
42
|
-
return ['REGEXP_SUBSTR', 'REGEXP_EXTRACT', 'REGEXP_REPLACE', 'REGEXP_MATCHES'].includes(name)
|
|
42
|
+
return ['REGEXP_SUBSTR', 'REGEXP_EXTRACT', 'REGEXP_REPLACE', 'REGEXP_MATCHES', 'REGEXP_LIKE'].includes(name)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -134,6 +134,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
134
134
|
REGEXP_EXTRACT: { min: 2, max: 4, signature: 'string, pattern[, position[, occurrence]]' },
|
|
135
135
|
REGEXP_REPLACE: { min: 3, max: 5, signature: 'string, pattern, replacement[, position[, occurrence]]' },
|
|
136
136
|
REGEXP_MATCHES: { min: 2, max: 2, signature: 'string, pattern' },
|
|
137
|
+
REGEXP_LIKE: { min: 2, max: 2, signature: 'string, pattern' },
|
|
137
138
|
|
|
138
139
|
// Date/time functions
|
|
139
140
|
RANDOM: { min: 0, max: 0, signature: '' },
|
|
@@ -178,6 +179,7 @@ export const FUNCTION_SIGNATURES = {
|
|
|
178
179
|
JSON_VALUE: { min: 2, max: 2, signature: 'expression, path' },
|
|
179
180
|
JSON_QUERY: { min: 2, max: 2, signature: 'expression, path' },
|
|
180
181
|
JSON_EXTRACT: { min: 2, max: 2, signature: 'expression, path' },
|
|
182
|
+
JSON_EXTRACT_STRING: { min: 2, max: 2, signature: 'expression, path' },
|
|
181
183
|
JSON_OBJECT: { min: 0, signature: 'key1, value1[, ...]' },
|
|
182
184
|
JSON_ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
|
|
183
185
|
JSON_VALID: { min: 1, max: 1, signature: 'value' },
|
|
@@ -221,6 +223,11 @@ export const FUNCTION_SIGNATURES = {
|
|
|
221
223
|
AVG: { min: 1, max: 1, signature: 'expression' },
|
|
222
224
|
MIN: { min: 1, max: 1, signature: 'expression' },
|
|
223
225
|
MAX: { min: 1, max: 1, signature: 'expression' },
|
|
226
|
+
MIN_BY: { min: 2, max: 2, signature: 'value, key' },
|
|
227
|
+
ARG_MIN: { min: 2, max: 2, signature: 'value, key' },
|
|
228
|
+
MAX_BY: { min: 2, max: 2, signature: 'value, key' },
|
|
229
|
+
ARG_MAX: { min: 2, max: 2, signature: 'value, key' },
|
|
230
|
+
ANY_VALUE: { min: 1, max: 1, signature: 'expression' },
|
|
224
231
|
STDDEV_SAMP: { min: 1, max: 1, signature: 'expression' },
|
|
225
232
|
STDDEV_POP: { min: 1, max: 1, signature: 'expression' },
|
|
226
233
|
MEDIAN: { min: 1, max: 1, signature: 'expression' },
|