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 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.0",
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.1.1",
42
+ "@types/node": "26.2.0",
43
43
  "@vitest/coverage-v8": "4.1.10",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.13",
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
- const val2 = evaluateExpr({ node: node.args[1], row, rowIndex, rows, context })
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
- if (isNaN(num)) return null
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') {
@@ -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
@@ -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' },