squirreling 0.1.1 → 0.2.0

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,11 +1,14 @@
1
1
  # Squirreling SQL Engine
2
2
 
3
+ ![squirreling engine](squirreling.jpg)
4
+
3
5
  [![npm](https://img.shields.io/npm/v/squirreling)](https://www.npmjs.com/package/squirreling)
4
6
  [![downloads](https://img.shields.io/npm/dt/squirreling)](https://www.npmjs.com/package/squirreling)
5
7
  [![minzipped](https://img.shields.io/bundlephobia/minzip/squirreling)](https://www.npmjs.com/package/squirreling)
6
8
  [![workflow status](https://github.com/hyparam/squirreling/actions/workflows/ci.yml/badge.svg)](https://github.com/hyparam/squirreling/actions)
7
9
  [![mit license](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT)
8
10
  [![dependencies](https://img.shields.io/badge/Dependencies-0-blueviolet)](https://www.npmjs.com/package/squirreling?activeTab=dependencies)
11
+
9
12
  Squirreling is a lightweight SQL engine for JavaScript applications, designed to provide efficient and easy-to-use database functionalities in the browser.
10
13
 
11
14
  ## Features
@@ -21,12 +24,12 @@ Squirreling is a lightweight SQL engine for JavaScript applications, designed to
21
24
  ```javascript
22
25
  import { executeSql } from 'squirreling'
23
26
 
24
- const data = [
27
+ const source = [
25
28
  { id: 1, name: 'Alice' },
26
29
  { id: 2, name: 'Bob' },
27
30
  ]
28
31
 
29
- const result = executeSql(data, 'SELECT UPPER(name) AS name_upper FROM users')
32
+ const result = executeSql({ source, sql: 'SELECT UPPER(name) AS name_upper FROM users' })
30
33
  console.log(result)
31
34
  // Output: [ { name_upper: 'ALICE' }, { name_upper: 'BOB' } ]
32
35
  ```
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
+ "description": "",
4
5
  "author": "Hyperparam",
5
6
  "homepage": "https://hyperparam.app",
7
+ "keywords": [
8
+ "sql",
9
+ "data",
10
+ "dataset",
11
+ "hyperparam",
12
+ "hyparquet",
13
+ "parquet"
14
+ ],
6
15
  "license": "MIT",
7
16
  "repository": {
8
17
  "type": "git",
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @import { DataSource, RowSource } from '../types.js'
3
+ */
4
+
5
+ /**
6
+ * Creates a row accessor that wraps a plain JavaScript object
7
+ *
8
+ * @param {Record<string, any>} obj - the plain object
9
+ * @returns {RowSource} a row accessor interface
10
+ */
11
+ export function createRowAccessor(obj) {
12
+ return {
13
+ getCell(name) {
14
+ return obj[name]
15
+ },
16
+ getKeys() {
17
+ return Object.keys(obj)
18
+ },
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Creates a memory-backed data source from an array of plain objects
24
+ *
25
+ * @param {Record<string, any>[]} data - array of plain objects
26
+ * @returns {DataSource} a data source interface
27
+ */
28
+ export function createMemorySource(data) {
29
+ return {
30
+ getNumRows() {
31
+ return data.length
32
+ },
33
+ getRow(index) {
34
+ return createRowAccessor(data[index])
35
+ },
36
+ }
37
+ }
@@ -3,9 +3,9 @@ import { evaluateExpr } from './expression.js'
3
3
  /**
4
4
  * Evaluates an aggregate function over a set of rows
5
5
  *
6
- * @import { AggregateColumn, ExprNode, Row } from '../types.js'
6
+ * @import { AggregateColumn, ExprNode, RowSource } from '../types.js'
7
7
  * @param {AggregateColumn} col - aggregate column definition
8
- * @param {Row[]} rows - rows to aggregate
8
+ * @param {RowSource[]} rows - rows to aggregate
9
9
  * @returns {number | null} aggregated result
10
10
  */
11
11
  export function evaluateAggregate(col, rows) {
@@ -77,8 +77,7 @@ export function defaultAggregateAlias(col) {
77
77
  * @param {ExprNode} expr
78
78
  * @returns {string}
79
79
  */
80
- export
81
- function defaultAggregateAliasExpr(expr) {
80
+ export function defaultAggregateAliasExpr(expr) {
82
81
  if (expr.type === 'identifier') {
83
82
  return expr.name
84
83
  }
@@ -1,26 +1,30 @@
1
1
  /**
2
- * @import { FunctionColumn, FunctionNode, OrderByItem, Row, SelectStatement, SqlPrimitive } from '../types.js'
2
+ * @import { DataSource, ExecuteSqlOptions, FunctionColumn, FunctionNode, OrderByItem, RowSource, SelectStatement, SqlPrimitive } from '../types.js'
3
3
  */
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'
9
+ import { createMemorySource, createRowAccessor } from '../backend/memory.js'
8
10
 
9
11
  /**
10
- * Executes a SQL SELECT query against an array of data rows
11
- * @param {Row[]} rows - The data rows to query
12
- * @param {string} sql - The SQL query string
13
- * @returns {Row[]} The result rows matching the query
12
+ * Executes a SQL SELECT query against a data source
13
+ *
14
+ * @param {ExecuteSqlOptions} options - the execution options
15
+ * @returns {Record<string, any>[]} the result rows matching the query
14
16
  */
15
- export function executeSql(rows, sql) {
17
+ export function executeSql({ source, sql }) {
16
18
  const select = parseSql(sql)
17
- return evaluateSelectAst(select, rows)
19
+ const dataSource = Array.isArray(source) ? createMemorySource(source) : source
20
+ return evaluateSelectAst(select, dataSource)
18
21
  }
19
22
 
20
23
  /**
21
24
  * Generates a default alias name for a string function
22
- * @param {FunctionColumn} col - The function column definition
23
- * @returns {string} The generated alias (e.g., "upper_name", "concat_a_b")
25
+ *
26
+ * @param {FunctionColumn} col - the function column definition
27
+ * @returns {string} the generated alias (e.g., "upper_name", "concat_a_b")
24
28
  */
25
29
  function defaultFunctionAlias(col) {
26
30
  const base = col.func.toLowerCase()
@@ -36,8 +40,9 @@ function defaultFunctionAlias(col) {
36
40
 
37
41
  /**
38
42
  * Creates a stable string key for a row to enable deduplication
39
- * @param {Row} row - The data row
40
- * @returns {string} A stable string representation of the row
43
+ *
44
+ * @param {Record<string, any>} row
45
+ * @returns {string} a stable string representation of the row
41
46
  */
42
47
  function stableRowKey(row) {
43
48
  const keys = Object.keys(row).sort()
@@ -52,9 +57,10 @@ function stableRowKey(row) {
52
57
 
53
58
  /**
54
59
  * Compares two SQL values for sorting
55
- * @param {SqlPrimitive} a - First value to compare
56
- * @param {SqlPrimitive} b - Second value to compare
57
- * @returns {number} Negative if a < b, positive if a > b, 0 if equal
60
+ *
61
+ * @param {SqlPrimitive} a
62
+ * @param {SqlPrimitive} b
63
+ * @returns {number} negative if a < b, positive if a > b, 0 if equal
58
64
  */
59
65
  function compareValues(a, b) {
60
66
  if (a === b) return 0
@@ -76,15 +82,16 @@ function compareValues(a, b) {
76
82
 
77
83
  /**
78
84
  * Applies DISTINCT filtering to remove duplicate rows
79
- * @param {Row[]} rows - The input rows
85
+ *
86
+ * @param {Record<string, any>[]} rows - The input rows
80
87
  * @param {boolean} distinct - Whether to apply deduplication
81
- * @returns {Row[]} The deduplicated rows
88
+ * @returns {Record<string, any>[]} The deduplicated rows
82
89
  */
83
90
  function applyDistinct(rows, distinct) {
84
91
  if (!distinct) return rows
85
92
  /** @type {Set<string>} */
86
93
  const seen = new Set()
87
- /** @type {Row[]} */
94
+ /** @type {Record<string, any>[]} */
88
95
  const result = []
89
96
  for (const row of rows) {
90
97
  const key = stableRowKey(row)
@@ -97,20 +104,20 @@ function applyDistinct(rows, distinct) {
97
104
 
98
105
  /**
99
106
  * Applies ORDER BY sorting to rows
100
- * @param {Row[]} rows - The input rows
101
- * @param {OrderByItem[]} orderBy - The sort specifications
102
- * @returns {Row[]} The sorted rows
107
+ *
108
+ * @param {Record<string, any>[]} rows - the input rows
109
+ * @param {OrderByItem[]} orderBy - the sort specifications
110
+ * @returns {Record<string, any>[]} the sorted rows
103
111
  */
104
112
  function applyOrderBy(rows, orderBy) {
105
- if (!orderBy || orderBy.length === 0) return rows
113
+ if (!orderBy?.length) return rows
106
114
 
107
115
  const sorted = rows.slice()
108
-
109
116
  sorted.sort((a, b) => {
110
117
  for (const term of orderBy) {
111
118
  const dir = term.direction
112
- const av = evaluateExpr(term.expr, a)
113
- const bv = evaluateExpr(term.expr, b)
119
+ const av = evaluateExpr(term.expr, createRowAccessor(a))
120
+ const bv = evaluateExpr(term.expr, createRowAccessor(b))
114
121
  const cmp = compareValues(av, bv)
115
122
  if (cmp !== 0) {
116
123
  return dir === 'DESC' ? -cmp : cmp
@@ -124,41 +131,41 @@ function applyOrderBy(rows, orderBy) {
124
131
 
125
132
  /**
126
133
  * Evaluates a parsed SELECT AST against data rows
127
- * @param {SelectStatement} select - The parsed SQL AST
128
- * @param {Row[]} rows - The data rows
129
- * @returns {Row[]} The filtered, projected, and sorted result rows
134
+ *
135
+ * @param {SelectStatement} select - the parsed SQL AST
136
+ * @param {DataSource} dataSource - the data source
137
+ * @returns {Record<string, any>[]} the filtered, projected, and sorted result rows
130
138
  */
131
- function evaluateSelectAst(select, rows) {
139
+ function evaluateSelectAst(select, dataSource) {
132
140
  // Check for unsupported JOIN operations
133
141
  if (select.joins.length) {
134
142
  throw new Error('JOIN is not supported')
135
143
  }
136
144
 
137
- // WHERE
138
- let working = rows
139
- if (select.where) {
140
- /** @type {Row[]} */
141
- const filtered = []
142
- for (const row of rows) {
143
- if (evaluateExpr(select.where, row)) {
144
- filtered.push(row)
145
- }
145
+ // WHERE clause filtering
146
+ /** @type {RowSource[]} */
147
+ const working = []
148
+ const length = dataSource.getNumRows()
149
+ for (let i = 0; i < length; i++) {
150
+ const row = dataSource.getRow(i)
151
+ if (!select.where || evaluateExpr(select.where, row)) {
152
+ working.push(row)
146
153
  }
147
- working = filtered
148
154
  }
149
155
 
150
156
  const hasAggregate = select.columns.some(col => col.kind === 'aggregate')
151
157
  const useGrouping = hasAggregate || select.groupBy?.length > 0
152
158
 
153
- /** @type {Row[]} */
159
+ /** @type {Record<string, any>[]} */
154
160
  const projected = []
155
161
 
156
162
  if (useGrouping) {
157
- /** @type {Row[][]} */
163
+ // Grouping due to GROUP BY or aggregate functions
164
+ /** @type {RowSource[][]} */
158
165
  const groups = []
159
166
 
160
167
  if (select.groupBy?.length) {
161
- /** @type {Map<string, Row[]>} */
168
+ /** @type {Map<string, RowSource[]>} */
162
169
  const map = new Map()
163
170
  for (const row of working) {
164
171
  /** @type {string[]} */
@@ -186,14 +193,16 @@ function evaluateSelectAst(select, rows) {
186
193
  }
187
194
 
188
195
  for (const group of groups) {
189
- /** @type {Row} */
196
+ /** @type {Record<string, any>} */
190
197
  const resultRow = {}
191
198
  for (const col of select.columns) {
192
199
  if (col.kind === 'star') {
193
- const firstRow = group[0] || {}
194
- const keys = Object.keys(firstRow)
195
- for (const key of keys) {
196
- resultRow[key] = firstRow[key]
200
+ const firstRow = group[0]
201
+ if (firstRow) {
202
+ const keys = firstRow.getKeys()
203
+ for (const key of keys) {
204
+ resultRow[key] = firstRow.getCell(key)
205
+ }
197
206
  }
198
207
  continue
199
208
  }
@@ -202,7 +211,7 @@ function evaluateSelectAst(select, rows) {
202
211
  const name = col.column
203
212
  const alias = col.alias ?? name
204
213
  // Evaluate on first row of group (all rows have same value for GROUP BY columns)
205
- resultRow[alias] = group.length > 0 ? group[0][name] : undefined
214
+ resultRow[alias] = group[0]?.getCell(name)
206
215
  continue
207
216
  }
208
217
 
@@ -230,22 +239,34 @@ function evaluateSelectAst(select, rows) {
230
239
  continue
231
240
  }
232
241
  }
242
+
243
+ // Apply HAVING filter before adding to projected results
244
+ if (select.having) {
245
+ // For HAVING, we need to evaluate aggregates in the context of the group
246
+ // Create a special row context that includes both the group data and aggregate values
247
+ const havingContext = createHavingContext(resultRow, group)
248
+ if (!evaluateHavingExpr(select.having, havingContext, group)) {
249
+ continue
250
+ }
251
+ }
252
+
233
253
  projected.push(resultRow)
234
254
  }
235
255
  } else {
256
+ // No grouping, simple projection
236
257
  for (const row of working) {
237
- /** @type {Row} */
258
+ /** @type {Record<string, any>} */
238
259
  const outRow = {}
239
260
  for (const col of select.columns) {
240
261
  if (col.kind === 'star') {
241
- const keys = Object.keys(row)
262
+ const keys = row.getKeys()
242
263
  for (const key of keys) {
243
- outRow[key] = row[key]
264
+ outRow[key] = row.getCell(key)
244
265
  }
245
266
  } else if (col.kind === 'column') {
246
267
  const name = col.column
247
268
  const alias = col.alias ?? name
248
- outRow[alias] = row[name]
269
+ outRow[alias] = row.getCell(name)
249
270
  } else if (col.kind === 'function') {
250
271
  /** @type {FunctionNode} */
251
272
  const funcNode = { type: 'function', name: col.func, args: col.args }
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * Evaluates an expression node against a row of data
4
4
  *
5
- * @import { ExprNode, Row, SqlPrimitive } from '../types.js'
5
+ * @import { ExprNode, RowSource, SqlPrimitive } from '../types.js'
6
6
  * @param {ExprNode} node - The expression node to evaluate
7
- * @param {Row} row - The data row to evaluate against
7
+ * @param {RowSource} row - The data row to evaluate against
8
8
  * @returns {SqlPrimitive} The result of the evaluation
9
9
  */
10
10
  export function evaluateExpr(node, row) {
@@ -13,9 +13,10 @@ export function evaluateExpr(node, row) {
13
13
  }
14
14
 
15
15
  if (node.type === 'identifier') {
16
- return row[node.name]
16
+ return row.getCell(node.name)
17
17
  }
18
18
 
19
+ // Unary operators
19
20
  if (node.type === 'unary') {
20
21
  if (node.op === 'NOT') {
21
22
  return !evaluateExpr(node.argument, row)
@@ -33,6 +34,7 @@ export function evaluateExpr(node, row) {
33
34
  }
34
35
  }
35
36
 
37
+ // Binary operators
36
38
  if (node.type === 'binary') {
37
39
  if (node.op === 'AND') {
38
40
  const leftVal = evaluateExpr(node.left, row)
@@ -79,6 +81,21 @@ export function evaluateExpr(node, row) {
79
81
  }
80
82
  }
81
83
 
84
+ // BETWEEN and NOT BETWEEN
85
+ if (node.type === 'between' || node.type === 'not between') {
86
+ const expr = evaluateExpr(node.expr, row)
87
+ const lower = evaluateExpr(node.lower, row)
88
+ const upper = evaluateExpr(node.upper, row)
89
+
90
+ // If any value is NULL, return false (SQL behavior)
91
+ if (expr == null || lower == null || upper == null) {
92
+ return false
93
+ }
94
+
95
+ const isBetween = expr >= lower && expr <= upper
96
+ return node.type === 'between' ? isBetween : !isBetween
97
+ }
98
+
82
99
  // Function calls
83
100
  if (node.type === 'function') {
84
101
  const funcName = node.name.toUpperCase()
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @import { AggregateFunc, ExprNode, RowSource, SqlPrimitive } from '../types.js'
3
+ */
4
+
5
+ import { isAggregateFunc } from '../validation.js'
6
+ import { evaluateExpr } from './expression.js'
7
+
8
+ /**
9
+ * Creates a context for evaluating HAVING expressions
10
+ *
11
+ * @param {Record<string, any>} resultRow - the aggregated result row
12
+ * @param {RowSource[]} group - the group of rows
13
+ * @returns {RowSource} a context row for HAVING evaluation
14
+ */
15
+ export function createHavingContext(resultRow, group) {
16
+ // Include the first row of the group (for GROUP BY columns)
17
+ const firstRow = group[0]
18
+ /** @type {Record<string, any>} */
19
+ const context = {}
20
+ if (firstRow) {
21
+ const keys = firstRow.getKeys()
22
+ for (const key of keys) {
23
+ context[key] = firstRow.getCell(key)
24
+ }
25
+ }
26
+ // Merge with result row (which has aggregates computed)
27
+ Object.assign(context, resultRow)
28
+
29
+ // Return a Row accessor wrapping the context
30
+ return {
31
+ getCell(name) {
32
+ return context[name]
33
+ },
34
+ getKeys() {
35
+ return Object.keys(context)
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Evaluates a HAVING expression with support for aggregate functions
42
+ *
43
+ * @param {ExprNode} expr - the HAVING expression
44
+ * @param {RowSource} context - the context row with aggregated values
45
+ * @param {RowSource[]} group - the group of rows for re-evaluating aggregates
46
+ * @returns {boolean} whether the HAVING condition is satisfied
47
+ */
48
+ export function evaluateHavingExpr(expr, context, group) {
49
+ // For HAVING, we need special handling of aggregate functions
50
+ // They need to be re-evaluated against the group
51
+ if (expr.type === 'function') {
52
+ const funcName = expr.name.toUpperCase()
53
+ if (isAggregateFunc(funcName)) {
54
+ // Evaluate aggregate function on the group
55
+ return Boolean(evaluateAggregateFunction(funcName, expr.args, group))
56
+ }
57
+ }
58
+
59
+ if (expr.type === 'binary') {
60
+ const left = evaluateHavingValue(expr.left, context, group)
61
+ const right = evaluateHavingValue(expr.right, context, group)
62
+
63
+ if (expr.op === 'AND') {
64
+ return Boolean(left && right)
65
+ }
66
+ if (expr.op === 'OR') {
67
+ return Boolean(left || right)
68
+ }
69
+
70
+ // Handle NULL comparisons
71
+ if (left == null || right == null) {
72
+ if (expr.op === '=' || expr.op === '!=' || expr.op === '<>') {
73
+ return false
74
+ }
75
+ }
76
+
77
+ if (expr.op === '=') return left === right
78
+ if (expr.op === '!=' || expr.op === '<>') return left !== right
79
+ if (expr.op === '<') return left < right
80
+ if (expr.op === '>') return left > right
81
+ if (expr.op === '<=') return left <= right
82
+ if (expr.op === '>=') return left >= right
83
+ if (expr.op === 'LIKE') {
84
+ const str = String(left)
85
+ const pattern = String(right)
86
+ const regexPattern = pattern
87
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
88
+ .replace(/%/g, '.*')
89
+ .replace(/_/g, '.')
90
+ const regex = new RegExp('^' + regexPattern + '$', 'i')
91
+ return regex.test(str)
92
+ }
93
+ }
94
+
95
+ if (expr.type === 'unary') {
96
+ if (expr.op === 'NOT') {
97
+ return !evaluateHavingExpr(expr.argument, context, group)
98
+ }
99
+ if (expr.op === 'IS NULL') {
100
+ return evaluateHavingValue(expr.argument, context, group) == null
101
+ }
102
+ if (expr.op === 'IS NOT NULL') {
103
+ return evaluateHavingValue(expr.argument, context, group) != null
104
+ }
105
+ }
106
+
107
+ if (expr.type === 'between' || expr.type === 'not between') {
108
+ const exprVal = evaluateHavingValue(expr.expr, context, group)
109
+ const lower = evaluateHavingValue(expr.lower, context, group)
110
+ const upper = evaluateHavingValue(expr.upper, context, group)
111
+
112
+ // If any value is NULL, return false (SQL behavior)
113
+ if (exprVal == null || lower == null || upper == null) {
114
+ return false
115
+ }
116
+
117
+ const isBetween = exprVal >= lower && exprVal <= upper
118
+ return expr.type === 'between' ? isBetween : !isBetween
119
+ }
120
+
121
+ // For other expression types, use the context row
122
+ return Boolean(evaluateExpr(expr, context))
123
+ }
124
+
125
+ /**
126
+ * Evaluates a value in a HAVING expression
127
+ *
128
+ * @param {ExprNode} expr
129
+ * @param {RowSource} context - the context row
130
+ * @param {RowSource[]} group - the group of rows
131
+ * @returns {SqlPrimitive} the evaluated value
132
+ */
133
+ function evaluateHavingValue(expr, context, group) {
134
+ if (expr.type === 'function') {
135
+ const funcName = expr.name.toUpperCase()
136
+ if (isAggregateFunc(funcName)) {
137
+ return evaluateAggregateFunction(funcName, expr.args, group)
138
+ }
139
+ }
140
+
141
+ // For binary expressions, we need to use evaluateHavingExpr to properly handle aggregates
142
+ if (expr.type === 'binary' || expr.type === 'unary' || expr.type === 'between' || expr.type === 'not between') {
143
+ return evaluateHavingExpr(expr, context, group)
144
+ }
145
+
146
+ return evaluateExpr(expr, context)
147
+ }
148
+
149
+ /**
150
+ * Evaluates an aggregate function on a group
151
+ *
152
+ * @param {AggregateFunc} funcName - aggregate function name
153
+ * @param {ExprNode[]} args - function arguments
154
+ * @param {RowSource[]} group - the group of rows
155
+ * @returns {SqlPrimitive} the aggregate result
156
+ */
157
+ function evaluateAggregateFunction(funcName, args, group) {
158
+ if (funcName === 'COUNT') {
159
+ if (args.length === 1 && args[0].type === 'identifier' && args[0].name === '*') {
160
+ return group.length
161
+ }
162
+ // COUNT(column) - count non-null values
163
+ let count = 0
164
+ for (const row of group) {
165
+ const val = evaluateExpr(args[0], row)
166
+ if (val != null) count++
167
+ }
168
+ return count
169
+ }
170
+
171
+ if (funcName === 'SUM') {
172
+ let sum = 0
173
+ for (const row of group) {
174
+ const val = evaluateExpr(args[0], row)
175
+ if (val != null) sum += Number(val)
176
+ }
177
+ return sum
178
+ }
179
+
180
+ if (funcName === 'AVG') {
181
+ let sum = 0
182
+ let count = 0
183
+ for (const row of group) {
184
+ const val = evaluateExpr(args[0], row)
185
+ if (val != null) {
186
+ sum += Number(val)
187
+ count++
188
+ }
189
+ }
190
+ return count > 0 ? sum / count : null
191
+ }
192
+
193
+ if (funcName === 'MIN') {
194
+ let min = null
195
+ for (const row of group) {
196
+ const val = evaluateExpr(args[0], row)
197
+ if (val != null && (min == null || val < min)) {
198
+ min = val
199
+ }
200
+ }
201
+ return min
202
+ }
203
+
204
+ if (funcName === 'MAX') {
205
+ let max = null
206
+ for (const row of group) {
207
+ const val = evaluateExpr(args[0], row)
208
+ if (val != null && (max == null || val > max)) {
209
+ max = val
210
+ }
211
+ }
212
+ return max
213
+ }
214
+
215
+ throw new Error('Unsupported aggregate function: ' + funcName)
216
+ }
package/src/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Row, SelectStatement } from './types.js'
1
+ import type { RowSource, SelectStatement } from './types.js'
2
2
 
3
3
  /**
4
4
  * Executes a SQL SELECT query against an array of data rows
@@ -7,7 +7,7 @@ import type { Row, SelectStatement } from './types.js'
7
7
  * @param sql - SQL query string
8
8
  * @returns rows matching the query
9
9
  */
10
- export function executeSql(rows: Row[], sql: string): Row[]
10
+ export function executeSql(rows: RowSource[], sql: string): RowSource[]
11
11
 
12
12
  /**
13
13
  * Parses a SQL query string into an abstract syntax tree
@@ -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)
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { tokenize } from './tokenize.js'
6
6
  import { parseExpression, parsePrimary } from './expression.js'
7
+ import { isAggregateFunc, isStringFunc } from '../validation.js'
7
8
 
8
9
  // Keywords that cannot be used as implicit aliases after a column
9
10
  const RESERVED_AFTER_COLUMN = new Set([
@@ -407,6 +408,8 @@ function parseSelectInternal(state) {
407
408
  let where
408
409
  /** @type {ExprNode[]} */
409
410
  const groupBy = []
411
+ /** @type {ExprNode | undefined} */
412
+ let having
410
413
  /** @type {OrderByItem[]} */
411
414
  const orderBy = []
412
415
  /** @type {number | undefined} */
@@ -429,6 +432,10 @@ function parseSelectInternal(state) {
429
432
  }
430
433
  }
431
434
 
435
+ if (match(state, 'keyword', 'HAVING')) {
436
+ having = parseExpression(cursor)
437
+ }
438
+
432
439
  if (match(state, 'keyword', 'ORDER')) {
433
440
  expect(state, 'keyword', 'BY')
434
441
  while (true) {
@@ -497,6 +504,7 @@ function parseSelectInternal(state) {
497
504
  joins,
498
505
  where,
499
506
  groupBy,
507
+ having,
500
508
  orderBy,
501
509
  limit,
502
510
  offset,
@@ -515,19 +523,3 @@ function parseError(state, expected) {
515
523
  const after = prevToken ? ` after "${prevToken.originalValue ?? prevToken.value}"` : ''
516
524
  return new Error(`Expected ${expected}${after} at position ${tok.position}`)
517
525
  }
518
-
519
- /**
520
- * @param {string} name
521
- * @returns {name is AggregateFunc}
522
- */
523
- function isAggregateFunc(name) {
524
- return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(name)
525
- }
526
-
527
- /**
528
- * @param {string} name
529
- * @returns {name is StringFunc}
530
- */
531
- function isStringFunc(name) {
532
- return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'TRIM'].includes(name)
533
- }
package/src/types.d.ts CHANGED
@@ -1,4 +1,17 @@
1
- export type Row = Record<string, any>
1
+ export interface RowSource {
2
+ getCell(name: string): any
3
+ getKeys(): string[]
4
+ }
5
+
6
+ export interface DataSource {
7
+ getNumRows(): number
8
+ getRow(index: number): RowSource
9
+ }
10
+
11
+ export interface ExecuteSqlOptions {
12
+ source: Record<string, any>[] | DataSource
13
+ sql: string
14
+ }
2
15
 
3
16
  export type SqlPrimitive = string | number | bigint | boolean | null
4
17
 
@@ -9,6 +22,7 @@ export interface SelectStatement {
9
22
  joins: JoinClause[]
10
23
  where?: ExprNode
11
24
  groupBy: ExprNode[]
25
+ having?: ExprNode
12
26
  orderBy: OrderByItem[]
13
27
  limit?: number
14
28
  offset?: number
@@ -81,7 +95,21 @@ export interface CastNode {
81
95
  toType: string
82
96
  }
83
97
 
84
- export type ExprNode = LiteralNode | IdentifierNode | UnaryNode | BinaryNode | FunctionNode | CastNode
98
+ export interface BetweenNode {
99
+ type: 'between' | 'not between'
100
+ expr: ExprNode
101
+ lower: ExprNode
102
+ upper: ExprNode
103
+ }
104
+
105
+ export type ExprNode =
106
+ | LiteralNode
107
+ | IdentifierNode
108
+ | UnaryNode
109
+ | BinaryNode
110
+ | FunctionNode
111
+ | CastNode
112
+ | BetweenNode
85
113
 
86
114
  export interface StarColumn {
87
115
  kind: 'star'
@@ -0,0 +1,17 @@
1
+
2
+ /**
3
+ * @import {AggregateFunc, StringFunc} from './types.js'
4
+ * @param {string} name
5
+ * @returns {name is AggregateFunc}
6
+ */
7
+ export function isAggregateFunc(name) {
8
+ return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(name)
9
+ }
10
+
11
+ /**
12
+ * @param {string} name
13
+ * @returns {name is StringFunc}
14
+ */
15
+ export function isStringFunc(name) {
16
+ return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'TRIM'].includes(name)
17
+ }