squirreling 0.7.8 → 0.7.10

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
@@ -104,4 +104,5 @@ Squirreling mostly follows the SQL standard. The following features are supporte
104
104
  - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
105
105
  - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
106
106
  - Regex: `REGEXP_SUBSTR`, `REGEXP_REPLACE`
107
+ - Conditional: `COALESCE`, `NULLIF`
107
108
  - User-defined functions (UDFs)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -37,11 +37,11 @@
37
37
  "test": "vitest run"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "25.0.9",
41
- "@vitest/coverage-v8": "4.0.17",
40
+ "@types/node": "25.2.0",
41
+ "@vitest/coverage-v8": "4.0.18",
42
42
  "eslint": "9.39.2",
43
- "eslint-plugin-jsdoc": "62.0.0",
43
+ "eslint-plugin-jsdoc": "62.5.0",
44
44
  "typescript": "5.9.3",
45
- "vitest": "4.0.17"
45
+ "vitest": "4.0.18"
46
46
  }
47
47
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AsyncCell, AsyncCells, AsyncDataSource, AsyncRow, ScanOptions, SqlPrimitive } from '../types.js'
2
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, ScanOptions, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -123,10 +123,20 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
123
123
  })
124
124
  }
125
125
 
126
+ // Apply FILTER clause if present
127
+ let filteredRows = rows
128
+ if (node.filter) {
129
+ filteredRows = []
130
+ for (const row of rows) {
131
+ const passes = await evaluateExpr({ node: node.filter, row, tables, functions })
132
+ if (passes) filteredRows.push(row)
133
+ }
134
+ }
135
+
126
136
  // Check for star argument (COUNT(*))
127
137
  if (node.args.length === 1 && node.args[0].type === 'identifier' && node.args[0].name === '*') {
128
138
  if (funcName === 'COUNT') {
129
- return rows.length
139
+ return filteredRows.length
130
140
  }
131
141
  throw aggregateError({
132
142
  funcName,
@@ -139,15 +149,15 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
139
149
  if (funcName === 'COUNT') {
140
150
  if (node.distinct) {
141
151
  const seen = new Set()
142
- for (const r of rows) {
143
- const v = await evaluateExpr({ node: argNode, row: r, tables, functions })
152
+ for (const row of filteredRows) {
153
+ const v = await evaluateExpr({ node: argNode, row, tables, functions })
144
154
  if (v != null) seen.add(v)
145
155
  }
146
156
  return seen.size
147
157
  }
148
158
  let count = 0
149
- for (const r of rows) {
150
- const v = await evaluateExpr({ node: argNode, row: r, tables, functions })
159
+ for (const row of filteredRows) {
160
+ const v = await evaluateExpr({ node: argNode, row, tables, functions })
151
161
  if (v != null) count++
152
162
  }
153
163
  return count
@@ -161,8 +171,8 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
161
171
  /** @type {number | null} */
162
172
  let max = null
163
173
 
164
- for (const r of rows) {
165
- const raw = await evaluateExpr({ node: argNode, row: r, tables, functions })
174
+ for (const row of filteredRows) {
175
+ const raw = await evaluateExpr({ node: argNode, row, tables, functions })
166
176
  if (raw == null) continue
167
177
  const num = Number(raw)
168
178
  if (!Number.isFinite(num)) continue
@@ -184,13 +194,32 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
184
194
  if (funcName === 'MAX') return max
185
195
  }
186
196
 
197
+ if (funcName === 'STDDEV_SAMP' || funcName === 'STDDEV_POP') {
198
+ const values = []
199
+ for (const row of filteredRows) {
200
+ const raw = await evaluateExpr({ node: argNode, row, tables, functions })
201
+ if (raw == null) continue
202
+ const num = Number(raw)
203
+ if (!Number.isFinite(num)) continue
204
+ values.push(num)
205
+ }
206
+ const n = values.length
207
+ if (n === 0) return null
208
+ if (funcName === 'STDDEV_SAMP' && n === 1) return null
209
+
210
+ const mean = values.reduce((a, b) => a + b, 0) / n
211
+ const squaredDiffs = values.reduce((acc, val) => acc + (val - mean) ** 2, 0)
212
+ const divisor = funcName === 'STDDEV_SAMP' ? n - 1 : n
213
+ return Math.sqrt(squaredDiffs / divisor)
214
+ }
215
+
187
216
  if (funcName === 'JSON_ARRAYAGG') {
188
217
  /** @type {SqlPrimitive[]} */
189
218
  const values = []
190
219
  if (node.distinct) {
191
220
  const seen = new Set()
192
- for (const r of rows) {
193
- const v = await evaluateExpr({ node: argNode, row: r, tables, functions })
221
+ for (const row of filteredRows) {
222
+ const v = await evaluateExpr({ node: argNode, row, tables, functions })
194
223
  const key = stringify(v)
195
224
  if (!seen.has(key)) {
196
225
  seen.add(key)
@@ -198,8 +227,8 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
198
227
  }
199
228
  }
200
229
  } else {
201
- for (const r of rows) {
202
- const v = await evaluateExpr({ node: argNode, row: r, tables, functions })
230
+ for (const row of filteredRows) {
231
+ const v = await evaluateExpr({ node: argNode, row, tables, functions })
203
232
  values.push(v)
204
233
  }
205
234
  }
@@ -239,6 +268,13 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
239
268
  return null
240
269
  }
241
270
 
271
+ if (funcName === 'NULLIF') {
272
+ // NULLIF(a, b) returns null if a = b, otherwise returns a
273
+ const val1 = await evaluateExpr({ node: node.args[0], row, tables, functions, rowIndex, rows })
274
+ const val2 = await evaluateExpr({ node: node.args[1], row, tables, functions, rowIndex, rows })
275
+ return val1 == val2 ? null : val1
276
+ }
277
+
242
278
  if (funcName === 'CURRENT_DATE') {
243
279
  return new Date().toISOString().split('T')[0]
244
280
  }
@@ -28,7 +28,7 @@ export async function evaluateHavingExpr({ expr, row, group, tables, functions }
28
28
  const funcName = expr.name.toUpperCase()
29
29
  if (isAggregateFunc(funcName)) {
30
30
  // Evaluate aggregate function on the group
31
- return Boolean(await evaluateAggregateFunction({ funcName, args: expr.args, group, tables, functions }))
31
+ return Boolean(await evaluateAggregateFunction({ funcName, args: expr.args, filter: expr.filter, group, tables, functions }))
32
32
  }
33
33
  }
34
34
 
@@ -78,7 +78,7 @@ function evaluateHavingValue({ expr, context, group, tables, functions }) {
78
78
  if (expr.type === 'function') {
79
79
  const funcName = expr.name.toUpperCase()
80
80
  if (isAggregateFunc(funcName)) {
81
- return evaluateAggregateFunction({ funcName, args: expr.args, group, tables, functions })
81
+ return evaluateAggregateFunction({ funcName, args: expr.args, filter: expr.filter, group, tables, functions })
82
82
  }
83
83
  }
84
84
 
@@ -96,19 +96,30 @@ function evaluateHavingValue({ expr, context, group, tables, functions }) {
96
96
  * @param {Object} options
97
97
  * @param {AggregateFunc} options.funcName - aggregate function name
98
98
  * @param {ExprNode[]} options.args - function arguments
99
+ * @param {ExprNode} [options.filter] - optional FILTER clause expression
99
100
  * @param {AsyncRow[]} options.group - the group of rows
100
101
  * @param {Record<string, AsyncDataSource>} options.tables
101
102
  * @param {Record<string, UserDefinedFunction>} [options.functions]
102
103
  * @returns {Promise<SqlPrimitive>} the aggregate result
103
104
  */
104
- async function evaluateAggregateFunction({ funcName, args, group, tables, functions }) {
105
+ async function evaluateAggregateFunction({ funcName, args, filter, group, tables, functions }) {
106
+ // Apply FILTER clause if present
107
+ let filteredGroup = group
108
+ if (filter) {
109
+ filteredGroup = []
110
+ for (const row of group) {
111
+ const passes = await evaluateExpr({ node: filter, row, tables, functions })
112
+ if (passes) filteredGroup.push(row)
113
+ }
114
+ }
115
+
105
116
  if (funcName === 'COUNT') {
106
117
  if (args.length === 1 && args[0].type === 'identifier' && args[0].name === '*') {
107
- return group.length
118
+ return filteredGroup.length
108
119
  }
109
120
  // COUNT(column) - count non-null values
110
121
  let count = 0
111
- for (const row of group) {
122
+ for (const row of filteredGroup) {
112
123
  const val = await evaluateExpr({ node: args[0], row, tables, functions })
113
124
  if (val != null) count++
114
125
  }
@@ -117,17 +128,21 @@ async function evaluateAggregateFunction({ funcName, args, group, tables, functi
117
128
 
118
129
  if (funcName === 'SUM') {
119
130
  let sum = 0
120
- for (const row of group) {
131
+ let hasValue = false
132
+ for (const row of filteredGroup) {
121
133
  const val = await evaluateExpr({ node: args[0], row, tables, functions })
122
- if (val != null) sum += Number(val)
134
+ if (val != null) {
135
+ sum += Number(val)
136
+ hasValue = true
137
+ }
123
138
  }
124
- return sum
139
+ return hasValue ? sum : null
125
140
  }
126
141
 
127
142
  if (funcName === 'AVG') {
128
143
  let sum = 0
129
144
  let count = 0
130
- for (const row of group) {
145
+ for (const row of filteredGroup) {
131
146
  const val = await evaluateExpr({ node: args[0], row, tables, functions })
132
147
  if (val != null) {
133
148
  sum += Number(val)
@@ -139,7 +154,7 @@ async function evaluateAggregateFunction({ funcName, args, group, tables, functi
139
154
 
140
155
  if (funcName === 'MIN') {
141
156
  let min = null
142
- for (const row of group) {
157
+ for (const row of filteredGroup) {
143
158
  const val = await evaluateExpr({ node: args[0], row, tables, functions })
144
159
  if (val != null && (min == null || val < min)) {
145
160
  min = val
@@ -150,7 +165,7 @@ async function evaluateAggregateFunction({ funcName, args, group, tables, functi
150
165
 
151
166
  if (funcName === 'MAX') {
152
167
  let max = null
153
- for (const row of group) {
168
+ for (const row of filteredGroup) {
154
169
  const val = await evaluateExpr({ node: args[0], row, tables, functions })
155
170
  if (val != null && (max == null || val > max)) {
156
171
  max = val
@@ -159,10 +174,29 @@ async function evaluateAggregateFunction({ funcName, args, group, tables, functi
159
174
  return max
160
175
  }
161
176
 
177
+ if (funcName === 'STDDEV_SAMP' || funcName === 'STDDEV_POP') {
178
+ const values = []
179
+ for (const row of filteredGroup) {
180
+ const val = await evaluateExpr({ node: args[0], row, tables, functions })
181
+ if (val == null) continue
182
+ const num = Number(val)
183
+ if (!Number.isFinite(num)) continue
184
+ values.push(num)
185
+ }
186
+ const n = values.length
187
+ if (n === 0) return null
188
+ if (funcName === 'STDDEV_SAMP' && n === 1) return null
189
+
190
+ const mean = values.reduce((a, b) => a + b, 0) / n
191
+ const squaredDiffs = values.reduce((acc, val) => acc + (val - mean) ** 2, 0)
192
+ const divisor = funcName === 'STDDEV_SAMP' ? n - 1 : n
193
+ return Math.sqrt(squaredDiffs / divisor)
194
+ }
195
+
162
196
  throw unknownFunctionError({
163
197
  funcName,
164
198
  positionStart: 0,
165
199
  positionEnd: 0,
166
- validFunctions: 'COUNT, SUM, AVG, MIN, MAX',
200
+ validFunctions: 'COUNT, SUM, AVG, MIN, MAX, STDDEV_SAMP, STDDEV_POP',
167
201
  })
168
202
  }
package/src/index.d.ts CHANGED
@@ -1,5 +1,18 @@
1
1
  import type { AsyncDataSource, AsyncRow, ExecuteSqlOptions, ParseSqlOptions, SelectStatement, SqlPrimitive, Token } from './types.js'
2
- export type { AsyncCells, AsyncDataSource, AsyncRow, ExprNode, ParseSqlOptions, SelectStatement, SqlPrimitive, Token, UserDefinedFunction } from './types.js'
2
+ export type {
3
+ AsyncCells,
4
+ AsyncDataSource,
5
+ AsyncRow,
6
+ ExecuteSqlOptions,
7
+ ExprNode,
8
+ ParseSqlOptions,
9
+ QueryHints,
10
+ ScanOptions,
11
+ SelectStatement,
12
+ SqlPrimitive,
13
+ Token,
14
+ UserDefinedFunction,
15
+ } from './types.js'
3
16
 
4
17
  /**
5
18
  * Executes a SQL SELECT query against an array of data rows
@@ -1,5 +1,5 @@
1
- import { argCountParseError } from '../parseErrors.js'
2
- import { validateFunctionArgCount } from '../validation.js'
1
+ import { argCountParseError, syntaxError } from '../parseErrors.js'
2
+ import { isAggregateFunc, validateFunctionArgCount } from '../validation.js'
3
3
  import { parseExpression } from './expression.js'
4
4
  import { consume, current, expect, lastPosition, match } from './state.js'
5
5
 
@@ -52,6 +52,26 @@ export function parseFunctionCall(state, funcName, positionStart) {
52
52
 
53
53
  expect(state, 'paren', ')')
54
54
 
55
+ // Check for FILTER clause (only valid for aggregate functions)
56
+ /** @type {ExprNode | undefined} */
57
+ let filter
58
+ if (current(state).type === 'keyword' && current(state).value === 'FILTER') {
59
+ const funcNameUpper = funcName.toUpperCase()
60
+ if (!isAggregateFunc(funcNameUpper)) {
61
+ throw syntaxError({
62
+ expected: 'aggregate function for FILTER clause',
63
+ received: `FILTER on non-aggregate function "${funcName}"`,
64
+ positionStart: current(state).positionStart,
65
+ positionEnd: current(state).positionEnd,
66
+ })
67
+ }
68
+ consume(state) // FILTER
69
+ expect(state, 'paren', '(')
70
+ expect(state, 'keyword', 'WHERE')
71
+ filter = parseExpression(state)
72
+ expect(state, 'paren', ')')
73
+ }
74
+
55
75
  // Validate argument count at parse time
56
76
  const funcNameUpper = funcName.toUpperCase()
57
77
  const validation = validateFunctionArgCount(funcNameUpper, args.length, state.functions)
@@ -70,6 +90,7 @@ export function parseFunctionCall(state, funcName, positionStart) {
70
90
  name: funcName,
71
91
  args,
72
92
  distinct: distinct || undefined,
93
+ filter,
73
94
  positionStart,
74
95
  positionEnd: lastPosition(state),
75
96
  }
@@ -24,21 +24,15 @@ export function parseJoins(state) {
24
24
  joinType = 'INNER'
25
25
  } else if (tok.value === 'LEFT') {
26
26
  consume(state)
27
- if (match(state, 'keyword', 'OUTER')) {
28
- // LEFT OUTER JOIN
29
- }
27
+ match(state, 'keyword', 'OUTER') // LEFT OUTER JOIN
30
28
  joinType = 'LEFT'
31
29
  } else if (tok.value === 'RIGHT') {
32
30
  consume(state)
33
- if (match(state, 'keyword', 'OUTER')) {
34
- // RIGHT OUTER JOIN
35
- }
31
+ match(state, 'keyword', 'OUTER') // RIGHT OUTER JOIN
36
32
  joinType = 'RIGHT'
37
33
  } else if (tok.value === 'FULL') {
38
34
  consume(state)
39
- if (match(state, 'keyword', 'OUTER')) {
40
- // FULL OUTER JOIN
41
- }
35
+ match(state, 'keyword', 'OUTER') // FULL OUTER JOIN
42
36
  joinType = 'FULL'
43
37
  } else if (tok.value === 'POSITIONAL') {
44
38
  consume(state)
@@ -56,6 +56,7 @@ const KEYWORDS = new Set([
56
56
  'HOUR',
57
57
  'MINUTE',
58
58
  'SECOND',
59
+ 'FILTER',
59
60
  ])
60
61
 
61
62
  /**
package/src/types.d.ts CHANGED
@@ -141,6 +141,7 @@ export interface FunctionNode extends ExprNodeBase {
141
141
  name: string
142
142
  args: ExprNode[]
143
143
  distinct?: boolean
144
+ filter?: ExprNode
144
145
  }
145
146
 
146
147
  export interface CastNode extends ExprNodeBase {
@@ -211,7 +212,7 @@ export interface StarColumn {
211
212
  alias?: string
212
213
  }
213
214
 
214
- export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG'
215
+ export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP'
215
216
 
216
217
  export type MathFunc =
217
218
  | 'FLOOR'
package/src/validation.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * @returns {name is AggregateFunc}
6
6
  */
7
7
  export function isAggregateFunc(name) {
8
- return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG'].includes(name)
8
+ return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP'].includes(name)
9
9
  }
10
10
 
11
11
  /**
@@ -117,6 +117,7 @@ export const FUNCTION_ARG_COUNTS = {
117
117
 
118
118
  // Conditional functions
119
119
  COALESCE: { min: 1 },
120
+ NULLIF: { min: 2, max: 2 },
120
121
 
121
122
  // Aggregate functions
122
123
  COUNT: { min: 1, max: 1 },
@@ -124,6 +125,8 @@ export const FUNCTION_ARG_COUNTS = {
124
125
  AVG: { min: 1, max: 1 },
125
126
  MIN: { min: 1, max: 1 },
126
127
  MAX: { min: 1, max: 1 },
128
+ STDDEV_SAMP: { min: 1, max: 1 },
129
+ STDDEV_POP: { min: 1, max: 1 },
127
130
  }
128
131
 
129
132
  /**
@@ -192,7 +195,7 @@ export function isKnownFunction(funcName, functions) {
192
195
  if ([
193
196
  'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP',
194
197
  'JSON_VALUE', 'JSON_QUERY', 'JSON_OBJECT',
195
- 'COALESCE', 'CAST',
198
+ 'COALESCE', 'NULLIF', 'CAST',
196
199
  ].includes(funcName)) {
197
200
  return true
198
201
  }
@@ -66,6 +66,8 @@ export const FUNCTION_SIGNATURES = {
66
66
  AVG: 'expression',
67
67
  MIN: 'expression',
68
68
  MAX: 'expression',
69
+ STDDEV_SAMP: 'expression',
70
+ STDDEV_POP: 'expression',
69
71
  }
70
72
 
71
73
  /**