squirreling 0.7.7 → 0.7.9

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
@@ -99,7 +99,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
99
99
 
100
100
  - Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
101
101
  - String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`
102
- - Math: `ABS`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
102
+ - Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
103
103
  - Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
104
104
  - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
105
105
  - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -37,7 +37,7 @@
37
37
  "test": "vitest run"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "25.0.7",
40
+ "@types/node": "25.0.9",
41
41
  "@vitest/coverage-v8": "4.0.17",
42
42
  "eslint": "9.39.2",
43
43
  "eslint-plugin-jsdoc": "62.0.0",
@@ -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
  }
@@ -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
  }
@@ -23,12 +23,27 @@ export function evaluateMathFunc({ funcName, args }) {
23
23
  return Math.ceil(Number(val))
24
24
  }
25
25
 
26
+ if (funcName === 'ROUND') {
27
+ const val = args[0]
28
+ if (val == null) return null
29
+ const decimals = args[1] ?? 0
30
+ if (decimals == null) return null
31
+ const multiplier = 10 ** Number(decimals)
32
+ return Math.round(Number(val) * multiplier) / multiplier
33
+ }
34
+
26
35
  if (funcName === 'ABS') {
27
36
  const val = args[0]
28
37
  if (val == null) return null
29
38
  return Math.abs(Number(val))
30
39
  }
31
40
 
41
+ if (funcName === 'SIGN') {
42
+ const val = args[0]
43
+ if (val == null) return null
44
+ return Math.sign(Number(val))
45
+ }
46
+
32
47
  if (funcName === 'MOD') {
33
48
  const dividend = args[0]
34
49
  const divisor = args[1]
@@ -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
  }
@@ -214,8 +214,7 @@ function parseFromSubquery(state) {
214
214
  expect(state, 'paren', '(')
215
215
  const query = parseSelectInternal(state)
216
216
  expect(state, 'paren', ')')
217
- expect(state, 'keyword', 'AS')
218
- const alias = expectIdentifier(state).value
217
+ const alias = parseTableAlias(state)
219
218
  return { kind: 'subquery', query, alias }
220
219
  }
221
220
 
@@ -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,13 +212,15 @@ 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'
218
219
  | 'CEIL'
219
220
  | 'CEILING'
221
+ | 'ROUND'
220
222
  | 'ABS'
223
+ | 'SIGN'
221
224
  | 'MOD'
222
225
  | 'EXP'
223
226
  | 'LN'
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
  /**
@@ -22,7 +22,7 @@ export function isRegexpFunc(name) {
22
22
  */
23
23
  export function isMathFunc(name) {
24
24
  return [
25
- 'FLOOR', 'CEIL', 'CEILING', 'ABS', 'MOD', 'EXP', 'LN', 'LOG10', 'POWER', 'SQRT',
25
+ 'FLOOR', 'CEIL', 'CEILING', 'ROUND', 'ABS', 'SIGN', 'MOD', 'EXP', 'LN', 'LOG10', 'POWER', 'SQRT',
26
26
  'SIN', 'COS', 'TAN', 'COT', 'ASIN', 'ACOS', 'ATAN', 'ATAN2', 'DEGREES', 'RADIANS', 'PI',
27
27
  'RAND', 'RANDOM',
28
28
  ].includes(name)
@@ -88,7 +88,9 @@ export const FUNCTION_ARG_COUNTS = {
88
88
  FLOOR: { min: 1, max: 1 },
89
89
  CEIL: { min: 1, max: 1 },
90
90
  CEILING: { min: 1, max: 1 },
91
+ ROUND: { min: 1, max: 2 },
91
92
  ABS: { min: 1, max: 1 },
93
+ SIGN: { min: 1, max: 1 },
92
94
  MOD: { min: 2, max: 2 },
93
95
  EXP: { min: 1, max: 1 },
94
96
  LN: { min: 1, max: 1 },
@@ -122,6 +124,8 @@ export const FUNCTION_ARG_COUNTS = {
122
124
  AVG: { min: 1, max: 1 },
123
125
  MIN: { min: 1, max: 1 },
124
126
  MAX: { min: 1, max: 1 },
127
+ STDDEV_SAMP: { min: 1, max: 1 },
128
+ STDDEV_POP: { min: 1, max: 1 },
125
129
  }
126
130
 
127
131
  /**
@@ -34,6 +34,7 @@ export const FUNCTION_SIGNATURES = {
34
34
  FLOOR: 'number',
35
35
  CEIL: 'number',
36
36
  CEILING: 'number',
37
+ ROUND: 'number[, decimals]',
37
38
  ABS: 'number',
38
39
  MOD: 'dividend, divisor',
39
40
  EXP: 'number',