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 +5 -2
- package/package.json +10 -1
- package/src/backend/memory.js +37 -0
- package/src/execute/aggregates.js +3 -4
- package/src/execute/execute.js +72 -51
- package/src/execute/expression.js +20 -3
- package/src/execute/having.js +216 -0
- package/src/index.d.ts +2 -2
- package/src/parse/expression.js +42 -2
- package/src/parse/parse.js +8 -16
- package/src/types.d.ts +30 -2
- package/src/validation.js +17 -0
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Squirreling SQL Engine
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
[](https://www.npmjs.com/package/squirreling)
|
|
4
6
|
[](https://www.npmjs.com/package/squirreling)
|
|
5
7
|
[](https://www.npmjs.com/package/squirreling)
|
|
6
8
|
[](https://github.com/hyparam/squirreling/actions)
|
|
7
9
|
[](https://opensource.org/licenses/MIT)
|
|
8
10
|
[](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
|
|
27
|
+
const source = [
|
|
25
28
|
{ id: 1, name: 'Alice' },
|
|
26
29
|
{ id: 2, name: 'Bob' },
|
|
27
30
|
]
|
|
28
31
|
|
|
29
|
-
const result = executeSql(
|
|
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.
|
|
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,
|
|
6
|
+
* @import { AggregateColumn, ExprNode, RowSource } from '../types.js'
|
|
7
7
|
* @param {AggregateColumn} col - aggregate column definition
|
|
8
|
-
* @param {
|
|
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
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { FunctionColumn, FunctionNode, OrderByItem,
|
|
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
|
|
11
|
-
*
|
|
12
|
-
* @param {
|
|
13
|
-
* @returns {
|
|
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(
|
|
17
|
+
export function executeSql({ source, sql }) {
|
|
16
18
|
const select = parseSql(sql)
|
|
17
|
-
|
|
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
|
-
*
|
|
23
|
-
* @
|
|
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
|
-
*
|
|
40
|
-
* @
|
|
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
|
-
*
|
|
56
|
-
* @param {SqlPrimitive}
|
|
57
|
-
* @
|
|
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
|
-
*
|
|
85
|
+
*
|
|
86
|
+
* @param {Record<string, any>[]} rows - The input rows
|
|
80
87
|
* @param {boolean} distinct - Whether to apply deduplication
|
|
81
|
-
* @returns {
|
|
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 {
|
|
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
|
-
*
|
|
101
|
-
* @param {
|
|
102
|
-
* @
|
|
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
|
|
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
|
-
*
|
|
128
|
-
* @param {
|
|
129
|
-
* @
|
|
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,
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 {
|
|
159
|
+
/** @type {Record<string, any>[]} */
|
|
154
160
|
const projected = []
|
|
155
161
|
|
|
156
162
|
if (useGrouping) {
|
|
157
|
-
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
262
|
+
const keys = row.getKeys()
|
|
242
263
|
for (const key of keys) {
|
|
243
|
-
outRow[key] = row
|
|
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
|
|
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,
|
|
5
|
+
* @import { ExprNode, RowSource, SqlPrimitive } from '../types.js'
|
|
6
6
|
* @param {ExprNode} node - The expression node to evaluate
|
|
7
|
-
* @param {
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
package/src/parse/expression.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
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)
|
package/src/parse/parse.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|