squirreling 0.1.0 → 0.1.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
@@ -1,6 +1,13 @@
1
1
  # Squirreling SQL Engine
2
2
 
3
+ ![squirreling engine](squirreling.jpg)
4
+
5
+ [![npm](https://img.shields.io/npm/v/squirreling)](https://www.npmjs.com/package/squirreling)
6
+ [![downloads](https://img.shields.io/npm/dt/squirreling)](https://www.npmjs.com/package/squirreling)
7
+ [![minzipped](https://img.shields.io/bundlephobia/minzip/squirreling)](https://www.npmjs.com/package/squirreling)
8
+ [![workflow status](https://github.com/hyparam/squirreling/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/squirreling/actions)
3
9
  [![mit license](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT)
10
+ [![dependencies](https://img.shields.io/badge/Dependencies-0-blueviolet)](https://www.npmjs.com/package/squirreling?activeTab=dependencies)
4
11
 
5
12
  Squirreling is a lightweight SQL engine for JavaScript applications, designed to provide efficient and easy-to-use database functionalities in the browser.
6
13
 
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "author": "Hyperparam",
5
5
  "homepage": "https://hyperparam.app",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/hyparam/squirrel.git"
9
+ "url": "git+https://github.com/hyparam/squirreling.git"
10
10
  },
11
11
  "type": "module",
12
12
  "sideEffects": false,
@@ -1,8 +1,9 @@
1
+ import { evaluateExpr } from './expression.js'
1
2
 
2
3
  /**
3
4
  * Evaluates an aggregate function over a set of rows
4
5
  *
5
- * @import { AggregateColumn, Row } from '../types.js'
6
+ * @import { AggregateColumn, ExprNode, Row } from '../types.js'
6
7
  * @param {AggregateColumn} col - aggregate column definition
7
8
  * @param {Row[]} rows - rows to aggregate
8
9
  * @returns {number | null} aggregated result
@@ -12,10 +13,9 @@ export function evaluateAggregate(col, rows) {
12
13
 
13
14
  if (func === 'COUNT') {
14
15
  if (arg.kind === 'star') return rows.length
15
- const field = arg.column
16
16
  let count = 0
17
17
  for (let i = 0; i < rows.length; i += 1) {
18
- const v = rows[i][field]
18
+ const v = evaluateExpr(arg.expr, rows[i])
19
19
  if (v !== null && v !== undefined) {
20
20
  count += 1
21
21
  }
@@ -27,7 +27,6 @@ export function evaluateAggregate(col, rows) {
27
27
  if (arg.kind === 'star') {
28
28
  throw new Error(func + '(*) is not supported, use a column name')
29
29
  }
30
- const field = arg.column
31
30
  let sum = 0
32
31
  let count = 0
33
32
  /** @type {number | null} */
@@ -36,7 +35,7 @@ export function evaluateAggregate(col, rows) {
36
35
  let max = null
37
36
 
38
37
  for (let i = 0; i < rows.length; i += 1) {
39
- const raw = rows[i][field]
38
+ const raw = evaluateExpr(arg.expr, rows[i])
40
39
  if (raw == null) continue
41
40
  const num = Number(raw)
42
41
  if (!Number.isFinite(num)) continue
@@ -63,11 +62,39 @@ export function evaluateAggregate(col, rows) {
63
62
 
64
63
  /**
65
64
  * Generates a default alias name for an aggregate function
66
- * @param {AggregateColumn} col - The aggregate column definition
67
- * @returns {string} The generated alias (e.g., "count_all", "sum_amount")
65
+ * (e.g., "count_all", "sum_amount")
66
+ *
67
+ * @param {AggregateColumn} col
68
+ * @returns {string}
68
69
  */
69
70
  export function defaultAggregateAlias(col) {
70
71
  const base = col.func.toLowerCase()
71
72
  if (col.arg.kind === 'star') return base + '_all'
72
- return base + '_' + col.arg.column
73
+ return base + '_' + defaultAggregateAliasExpr(col.arg.expr)
74
+ }
75
+
76
+ /**
77
+ * @param {ExprNode} expr
78
+ * @returns {string}
79
+ */
80
+ export
81
+ function defaultAggregateAliasExpr(expr) {
82
+ if (expr.type === 'identifier') {
83
+ return expr.name
84
+ }
85
+ if (expr.type === 'literal') {
86
+ return String(expr.value)
87
+ }
88
+ if (expr.type === 'cast') {
89
+ return defaultAggregateAliasExpr(expr.expr) + '_as_' + expr.toType
90
+ }
91
+ if (expr.type === 'unary') {
92
+ return expr.op + '_' + defaultAggregateAliasExpr(expr.argument)
93
+ }
94
+ if (expr.type === 'binary') {
95
+ return defaultAggregateAliasExpr(expr.left) + '_' + expr.op + '_' + defaultAggregateAliasExpr(expr.right)
96
+ }
97
+ if (expr.type === 'function') {
98
+ return expr.name.toLowerCase() + '_' + expr.args.map(defaultAggregateAliasExpr).join('_')
99
+ }
73
100
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
6
6
  import { evaluateExpr } from './expression.js'
7
+ import { createHavingContext, evaluateHavingExpr } from './having.js'
7
8
  import { parseSql } from '../parse/parse.js'
8
9
 
9
10
  /**
@@ -230,6 +231,17 @@ function evaluateSelectAst(select, rows) {
230
231
  continue
231
232
  }
232
233
  }
234
+
235
+ // Apply HAVING filter before adding to projected results
236
+ if (select.having) {
237
+ // For HAVING, we need to evaluate aggregates in the context of the group
238
+ // Create a special row context that includes both the group data and aggregate values
239
+ const havingContext = createHavingContext(resultRow, group)
240
+ if (!evaluateHavingExpr(select.having, havingContext, group)) {
241
+ continue
242
+ }
243
+ }
244
+
233
245
  projected.push(resultRow)
234
246
  }
235
247
  } else {
@@ -79,6 +79,21 @@ export function evaluateExpr(node, row) {
79
79
  }
80
80
  }
81
81
 
82
+ // BETWEEN and NOT BETWEEN
83
+ if (node.type === 'between' || node.type === 'not between') {
84
+ const expr = evaluateExpr(node.expr, row)
85
+ const lower = evaluateExpr(node.lower, row)
86
+ const upper = evaluateExpr(node.upper, row)
87
+
88
+ // If any value is NULL, return false (SQL behavior)
89
+ if (expr == null || lower == null || upper == null) {
90
+ return false
91
+ }
92
+
93
+ const isBetween = expr >= lower && expr <= upper
94
+ return node.type === 'between' ? isBetween : !isBetween
95
+ }
96
+
82
97
  // Function calls
83
98
  if (node.type === 'function') {
84
99
  const funcName = node.name.toUpperCase()
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @import { ExprNode, Row, SqlPrimitive } from '../types.js'
3
+ */
4
+
5
+ import { evaluateExpr } from './expression.js'
6
+
7
+ /**
8
+ * Creates a context for evaluating HAVING expressions
9
+ * @param {Row} resultRow - The aggregated result row
10
+ * @param {Row[]} group - The group of rows
11
+ * @returns {Row} A context row for HAVING evaluation
12
+ */
13
+ export function createHavingContext(resultRow, group) {
14
+ // Include the first row of the group (for GROUP BY columns)
15
+ const firstRow = group[0] || {}
16
+ // Merge with result row (which has aggregates computed)
17
+ return { ...firstRow, ...resultRow }
18
+ }
19
+
20
+ /**
21
+ * Evaluates a HAVING expression with support for aggregate functions
22
+ * @param {ExprNode} expr - The HAVING expression
23
+ * @param {Row} context - The context row with aggregated values
24
+ * @param {Row[]} group - The group of rows for re-evaluating aggregates
25
+ * @returns {boolean} Whether the HAVING condition is satisfied
26
+ */
27
+ export function evaluateHavingExpr(expr, context, group) {
28
+ // For HAVING, we need special handling of aggregate functions
29
+ // They need to be re-evaluated against the group
30
+ if (expr.type === 'function') {
31
+ const funcName = expr.name.toUpperCase()
32
+ if (['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(funcName)) {
33
+ // Evaluate aggregate function on the group
34
+ return Boolean(evaluateAggregateFunction(funcName, expr.args, group))
35
+ }
36
+ }
37
+
38
+ if (expr.type === 'binary') {
39
+ const left = evaluateHavingValue(expr.left, context, group)
40
+ const right = evaluateHavingValue(expr.right, context, group)
41
+
42
+ if (expr.op === 'AND') {
43
+ return Boolean(left && right)
44
+ }
45
+ if (expr.op === 'OR') {
46
+ return Boolean(left || right)
47
+ }
48
+
49
+ // Handle NULL comparisons
50
+ if (left == null || right == null) {
51
+ if (expr.op === '=' || expr.op === '!=' || expr.op === '<>') {
52
+ return false
53
+ }
54
+ }
55
+
56
+ if (expr.op === '=') return left === right
57
+ if (expr.op === '!=' || expr.op === '<>') return left !== right
58
+ if (expr.op === '<') return left < right
59
+ if (expr.op === '>') return left > right
60
+ if (expr.op === '<=') return left <= right
61
+ if (expr.op === '>=') return left >= right
62
+ if (expr.op === 'LIKE') {
63
+ const str = String(left)
64
+ const pattern = String(right)
65
+ const regexPattern = pattern
66
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
+ .replace(/%/g, '.*')
68
+ .replace(/_/g, '.')
69
+ const regex = new RegExp('^' + regexPattern + '$', 'i')
70
+ return regex.test(str)
71
+ }
72
+ }
73
+
74
+ if (expr.type === 'unary') {
75
+ if (expr.op === 'NOT') {
76
+ return !evaluateHavingExpr(expr.argument, context, group)
77
+ }
78
+ if (expr.op === 'IS NULL') {
79
+ return evaluateHavingValue(expr.argument, context, group) == null
80
+ }
81
+ if (expr.op === 'IS NOT NULL') {
82
+ return evaluateHavingValue(expr.argument, context, group) != null
83
+ }
84
+ }
85
+
86
+ if (expr.type === 'between' || expr.type === 'not between') {
87
+ const exprVal = evaluateHavingValue(expr.expr, context, group)
88
+ const lower = evaluateHavingValue(expr.lower, context, group)
89
+ const upper = evaluateHavingValue(expr.upper, context, group)
90
+
91
+ // If any value is NULL, return false (SQL behavior)
92
+ if (exprVal == null || lower == null || upper == null) {
93
+ return false
94
+ }
95
+
96
+ const isBetween = exprVal >= lower && exprVal <= upper
97
+ return expr.type === 'between' ? isBetween : !isBetween
98
+ }
99
+
100
+ // For other expression types, use the context row
101
+ return Boolean(evaluateExpr(expr, context))
102
+ }
103
+
104
+ /**
105
+ * Evaluates a value in a HAVING expression
106
+ * @param {ExprNode} expr - The expression
107
+ * @param {Row} context - The context row
108
+ * @param {Row[]} group - The group of rows
109
+ * @returns {SqlPrimitive} The evaluated value
110
+ */
111
+ function evaluateHavingValue(expr, context, group) {
112
+ if (expr.type === 'function') {
113
+ const funcName = expr.name.toUpperCase()
114
+ if (['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(funcName)) {
115
+ return evaluateAggregateFunction(funcName, expr.args, group)
116
+ }
117
+ }
118
+
119
+ // For binary expressions, we need to use evaluateHavingExpr to properly handle aggregates
120
+ if (expr.type === 'binary' || expr.type === 'unary' || expr.type === 'between' || expr.type === 'not between') {
121
+ return evaluateHavingExpr(expr, context, group)
122
+ }
123
+
124
+ return evaluateExpr(expr, context)
125
+ }
126
+
127
+ /**
128
+ * Evaluates an aggregate function on a group
129
+ * @param {string} funcName - The aggregate function name
130
+ * @param {ExprNode[]} args - The function arguments
131
+ * @param {Row[]} group - The group of rows
132
+ * @returns {SqlPrimitive} The aggregate result
133
+ */
134
+ function evaluateAggregateFunction(funcName, args, group) {
135
+ if (funcName === 'COUNT') {
136
+ if (args.length === 1 && args[0].type === 'identifier' && args[0].name === '*') {
137
+ return group.length
138
+ }
139
+ // COUNT(column) - count non-null values
140
+ let count = 0
141
+ for (const row of group) {
142
+ const val = evaluateExpr(args[0], row)
143
+ if (val != null) count++
144
+ }
145
+ return count
146
+ }
147
+
148
+ if (funcName === 'SUM') {
149
+ let sum = 0
150
+ for (const row of group) {
151
+ const val = evaluateExpr(args[0], row)
152
+ if (val != null) sum += Number(val)
153
+ }
154
+ return sum
155
+ }
156
+
157
+ if (funcName === 'AVG') {
158
+ let sum = 0
159
+ let count = 0
160
+ for (const row of group) {
161
+ const val = evaluateExpr(args[0], row)
162
+ if (val != null) {
163
+ sum += Number(val)
164
+ count++
165
+ }
166
+ }
167
+ return count > 0 ? sum / count : null
168
+ }
169
+
170
+ if (funcName === 'MIN') {
171
+ let min = null
172
+ for (const row of group) {
173
+ const val = evaluateExpr(args[0], row)
174
+ if (val != null && (min == null || val < min)) {
175
+ min = val
176
+ }
177
+ }
178
+ return min
179
+ }
180
+
181
+ if (funcName === 'MAX') {
182
+ let max = null
183
+ for (const row of group) {
184
+ const val = evaluateExpr(args[0], row)
185
+ if (val != null && (max == null || val > max)) {
186
+ max = val
187
+ }
188
+ }
189
+ return max
190
+ }
191
+
192
+ throw new Error('Unsupported aggregate function: ' + funcName)
193
+ }
@@ -42,8 +42,17 @@ export function parsePrimary(c) {
42
42
 
43
43
  if (c.current().type !== 'paren' || c.current().value !== ')') {
44
44
  while (true) {
45
- const arg = parseExpression(c)
46
- args.push(arg)
45
+ // Handle COUNT(*) - treat * as a special identifier
46
+ if (c.current().type === 'operator' && c.current().value === '*') {
47
+ c.consume()
48
+ args.push({
49
+ type: 'identifier',
50
+ name: '*',
51
+ })
52
+ } else {
53
+ const arg = parseExpression(c)
54
+ args.push(arg)
55
+ }
47
56
  if (!c.match('comma')) break
48
57
  }
49
58
  }
@@ -217,6 +226,37 @@ function parseComparison(c) {
217
226
  }
218
227
  }
219
228
 
229
+ // [NOT] BETWEEN
230
+ if (tok.type === 'keyword' && tok.value === 'NOT') {
231
+ const nextTok = c.peek(1)
232
+ if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
233
+ c.consume() // NOT
234
+ c.consume() // BETWEEN
235
+ const lower = parsePrimary(c)
236
+ c.expect('keyword', 'AND')
237
+ const upper = parsePrimary(c)
238
+ return {
239
+ type: 'not between',
240
+ expr: left,
241
+ lower,
242
+ upper,
243
+ }
244
+ }
245
+ }
246
+
247
+ if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
248
+ c.consume()
249
+ const lower = parsePrimary(c)
250
+ c.expect('keyword', 'AND')
251
+ const upper = parsePrimary(c)
252
+ return {
253
+ type: 'between',
254
+ expr: left,
255
+ lower,
256
+ upper,
257
+ }
258
+ }
259
+
220
260
  if (tok.type === 'operator' && isComparisonOperator(tok.value)) {
221
261
  c.consume()
222
262
  const right = parsePrimary(c)
@@ -226,11 +226,24 @@ function parseAggregateItem(state, func) {
226
226
  if (cur.type === 'operator' && cur.value === '*') {
227
227
  consume(state)
228
228
  arg = { kind: 'star' }
229
+ } else if (cur.type === 'identifier' && cur.value === 'CAST') {
230
+ // Handle CAST inside aggregate: SUM(CAST(x AS type))
231
+ expectIdentifier(state) // consume CAST
232
+ expect(state, 'paren', '(')
233
+ const cursor = createExprCursor(state)
234
+ const expr = parseExpression(cursor)
235
+ expect(state, 'keyword', 'AS')
236
+ const typeTok = expectIdentifier(state)
237
+ expect(state, 'paren', ')')
238
+ arg = {
239
+ kind: 'expression',
240
+ expr: { type: 'cast', expr, toType: typeTok.value },
241
+ }
229
242
  } else {
230
243
  const colTok = expectIdentifier(state)
231
244
  arg = {
232
- kind: 'column',
233
- column: colTok.value,
245
+ kind: 'expression',
246
+ expr: { type: 'identifier', name: colTok.value },
234
247
  }
235
248
  }
236
249
 
@@ -394,6 +407,8 @@ function parseSelectInternal(state) {
394
407
  let where
395
408
  /** @type {ExprNode[]} */
396
409
  const groupBy = []
410
+ /** @type {ExprNode | undefined} */
411
+ let having
397
412
  /** @type {OrderByItem[]} */
398
413
  const orderBy = []
399
414
  /** @type {number | undefined} */
@@ -416,6 +431,10 @@ function parseSelectInternal(state) {
416
431
  }
417
432
  }
418
433
 
434
+ if (match(state, 'keyword', 'HAVING')) {
435
+ having = parseExpression(cursor)
436
+ }
437
+
419
438
  if (match(state, 'keyword', 'ORDER')) {
420
439
  expect(state, 'keyword', 'BY')
421
440
  while (true) {
@@ -484,6 +503,7 @@ function parseSelectInternal(state) {
484
503
  joins,
485
504
  where,
486
505
  groupBy,
506
+ having,
487
507
  orderBy,
488
508
  limit,
489
509
  offset,
package/src/types.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface SelectStatement {
9
9
  joins: JoinClause[]
10
10
  where?: ExprNode
11
11
  groupBy: ExprNode[]
12
+ having?: ExprNode
12
13
  orderBy: OrderByItem[]
13
14
  limit?: number
14
15
  offset?: number
@@ -81,7 +82,21 @@ export interface CastNode {
81
82
  toType: string
82
83
  }
83
84
 
84
- export type ExprNode = LiteralNode | IdentifierNode | UnaryNode | BinaryNode | FunctionNode | CastNode
85
+ export interface BetweenNode {
86
+ type: 'between' | 'not between'
87
+ expr: ExprNode
88
+ lower: ExprNode
89
+ upper: ExprNode
90
+ }
91
+
92
+ export type ExprNode =
93
+ | LiteralNode
94
+ | IdentifierNode
95
+ | UnaryNode
96
+ | BinaryNode
97
+ | FunctionNode
98
+ | CastNode
99
+ | BetweenNode
85
100
 
86
101
  export interface StarColumn {
87
102
  kind: 'star'
@@ -102,12 +117,12 @@ export interface AggregateArgStar {
102
117
  kind: 'star'
103
118
  }
104
119
 
105
- export interface AggregateArgColumn {
106
- kind: 'column'
107
- column: string
120
+ export interface AggregateArgExpression {
121
+ kind: 'expression'
122
+ expr: ExprNode
108
123
  }
109
124
 
110
- export type AggregateArg = AggregateArgStar | AggregateArgColumn
125
+ export type AggregateArg = AggregateArgStar | AggregateArgExpression
111
126
 
112
127
  export interface AggregateColumn {
113
128
  kind: 'aggregate'