squirreling 0.15.1 → 0.15.3

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.1",
3
+ "version": "0.15.3",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
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 {
@@ -29,12 +29,25 @@ export function applyBinaryOp(op, a, b) {
29
29
  return String(a) + String(b)
30
30
  }
31
31
 
32
- // Comparison and logical operators
33
- if (a == null || b == null) {
32
+ // Logical operators use Kleene three-valued logic: null is UNKNOWN, which
33
+ // only resolves when the other operand decides the answer on its own
34
+ if (op === 'AND') {
35
+ if (a != null && !a || b != null && !b) return false
36
+ if (a == null || b == null) return null
37
+ return true
38
+ }
39
+ if (op === 'OR') {
40
+ if (a != null && Boolean(a) || b != null && Boolean(b)) return true
41
+ if (a == null || b == null) return null
34
42
  return false
35
43
  }
36
- if (op === 'AND') return Boolean(a) && Boolean(b)
37
- if (op === 'OR') return Boolean(a) || Boolean(b)
44
+
45
+ // A comparison with a null operand is UNKNOWN, not false. The difference is
46
+ // invisible to a WHERE (both exclude the row) but not to a NOT above it,
47
+ // which must keep UNKNOWN as UNKNOWN rather than flip false to true
48
+ if (a == null || b == null) {
49
+ return null
50
+ }
38
51
  // Compare Date values by their time so distinct instances for the same
39
52
  // instant are equal, matching SQL TIMESTAMP semantics rather than JS identity.
40
53
  if (a instanceof Date && b instanceof Date) {
@@ -167,7 +167,9 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
167
167
  if (val == null) return null
168
168
  return -val
169
169
  }
170
- if (node.op === 'NOT') return !val
170
+ // NOT UNKNOWN is UNKNOWN: JS ! would flip null to true and return rows
171
+ // SQL excludes, e.g. WHERE NOT (nullable < 300)
172
+ if (node.op === 'NOT') return val == null ? null : !val
171
173
  if (node.op === 'IS NULL') return val == null
172
174
  if (node.op === 'IS NOT NULL') return val != null
173
175
  }
@@ -186,9 +188,11 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
186
188
 
187
189
  const left = await evaluateExpr({ node: node.left, row, rowIndex, rows, context })
188
190
 
189
- // Short-circuit evaluation for AND and OR
190
- if (node.op === 'AND' && !left) return false
191
- if (node.op === 'OR' && left) return true
191
+ // Short-circuit evaluation for AND and OR. A null left cannot
192
+ // short-circuit AND: NULL AND FALSE is false but NULL AND TRUE is null,
193
+ // so the right side decides
194
+ if (node.op === 'AND' && left != null && !left) return false
195
+ if (node.op === 'OR' && left != null && Boolean(left)) return true
192
196
 
193
197
  const right = await evaluateExpr({ node: node.right, row, rowIndex, rows, context })
194
198
  return applyBinaryOp(node.op, left, right)
@@ -289,6 +293,37 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
289
293
  if (funcName === 'MAX') return max
290
294
  }
291
295
 
296
+ if (funcName === 'MIN_BY' || funcName === 'ARG_MIN' || funcName === 'MAX_BY' || funcName === 'ARG_MAX') {
297
+ // Returns the value at the row with the smallest (or largest) key.
298
+ // Rows with a null value or key are ignored, and ties keep the first row.
299
+ const isMin = funcName === 'MIN_BY' || funcName === 'ARG_MIN'
300
+ const values = await evaluateAll(argNode, filteredRows, context)
301
+ const keys = await evaluateAll(node.args[1], filteredRows, context)
302
+ /** @type {SqlPrimitive} */
303
+ let best = null
304
+ /** @type {SqlPrimitive} */
305
+ let bestKey = null
306
+ for (let i = 0; i < values.length; i++) {
307
+ const value = values[i]
308
+ const key = keys[i]
309
+ if (value == null || key == null) continue
310
+ if (bestKey === null || (isMin ? key < bestKey : key > bestKey)) {
311
+ best = value
312
+ bestKey = key
313
+ }
314
+ }
315
+ return best
316
+ }
317
+
318
+ if (funcName === 'ANY_VALUE') {
319
+ // Returns an arbitrary non-null value from the group; we pick the first
320
+ const values = await evaluateAll(argNode, filteredRows, context)
321
+ for (const v of values) {
322
+ if (v != null) return v
323
+ }
324
+ return null
325
+ }
326
+
292
327
  if (funcName === 'STDDEV_SAMP' || funcName === 'STDDEV_POP') {
293
328
  const rawValues = await evaluateAll(argNode, filteredRows, context)
294
329
  let sum = 0
@@ -633,7 +668,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
633
668
  })
634
669
  }
635
670
 
636
- if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT') {
671
+ if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY' || funcName === 'JSON_EXTRACT' || funcName === 'JSON_EXTRACT_STRING') {
637
672
  let jsonArg = args[0]
638
673
  const pathArg = args[1]
639
674
  if (jsonArg == null || pathArg == null) return null
@@ -682,6 +717,11 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
682
717
  }
683
718
 
684
719
  if (current == null) return null
720
+ // JSON_EXTRACT_STRING returns text: unquoted scalars, JSON text for objects and arrays
721
+ if (funcName === 'JSON_EXTRACT_STRING') {
722
+ if (typeof current === 'object') return JSON.stringify(current)
723
+ return String(current)
724
+ }
685
725
  return current
686
726
  }
687
727
 
@@ -707,6 +747,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
707
747
  }
708
748
  // Can only cast primitives (and Dates) to other primitive types
709
749
  if (typeof val === 'object' && !(val instanceof Date)) {
750
+ if (node.tryCast) return null
710
751
  throw new ExecutionError({ message: `Cannot CAST object to ${toType}`, rowIndex, ...node })
711
752
  }
712
753
  if (toType === 'INTEGER' || toType === 'INT') {
@@ -717,7 +758,8 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
717
758
  if (toType === 'BIGINT') {
718
759
  if (typeof val === 'bigint') return val
719
760
  const num = Number(val)
720
- if (isNaN(num)) return null
761
+ // NaN and Infinity have no bigint representation
762
+ if (!isFinite(num)) return null
721
763
  return BigInt(Math.trunc(num))
722
764
  }
723
765
  if (toType === 'FLOAT' || toType === 'REAL' || toType === 'DOUBLE') {
@@ -738,19 +780,28 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
738
780
  }
739
781
  }
740
782
 
741
- // IN and NOT IN with value lists
783
+ // IN and NOT IN with value lists. IN is a chain of OR'd equalities, so it
784
+ // inherits their three-valued logic: a null on either side of any equality
785
+ // makes a non-match UNKNOWN rather than false, and NOT above it must not
786
+ // turn that into true
742
787
  if (node.type === 'in valuelist') {
743
788
  const exprVal = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
789
+ let sawNull = exprVal == null
744
790
  for (const valueNode of node.values) {
745
791
  const val = await evaluateExpr({ node: valueNode, row, rowIndex, rows, context })
746
- if (sqlEquals(exprVal, val)) return true
792
+ if (val == null) {
793
+ sawNull = true
794
+ } else if (exprVal != null && sqlEquals(exprVal, val)) {
795
+ return true
796
+ }
747
797
  }
748
- return false
798
+ return sawNull ? null : false
749
799
  }
750
- // IN with subqueries
800
+ // IN with subqueries, same three-valued logic as the value-list form
751
801
  if (node.type === 'in') {
752
802
  const exprVal = await evaluateExpr({ node: node.expr, row, rowIndex, rows, context })
753
803
  const subResult = executeStatement({ query: node.subquery, context })
804
+ let sawNull = false
754
805
  let innerCount = 0
755
806
  for await (const resRow of subResult.rows()) {
756
807
  if (++innerCount % YIELD_INTERVAL === 0) {
@@ -758,9 +809,13 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
758
809
  context.signal?.throwIfAborted()
759
810
  }
760
811
  const value = await resRow.cells[resRow.columns[0]]()
761
- if (sqlEquals(exprVal, value)) return true
812
+ if (exprVal == null || value == null) {
813
+ sawNull = true
814
+ } else if (exprVal != null && sqlEquals(exprVal, value)) {
815
+ return true
816
+ }
762
817
  }
763
- return false
818
+ return sawNull ? null : false
764
819
  }
765
820
 
766
821
  // EXISTS and NOT EXISTS with subqueries
@@ -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' },