squirreling 0.1.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/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ The MIT License (MIT)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Squirreling SQL Engine
2
+
3
+ [![mit license](https://img.shields.io/badge/License-MIT-orange.svg)](https://opensource.org/licenses/MIT)
4
+
5
+ Squirreling is a lightweight SQL engine for JavaScript applications, designed to provide efficient and easy-to-use database functionalities in the browser.
6
+
7
+ ## Features
8
+
9
+ - Lightweight and fast
10
+ - Easy to integrate with JavaScript applications
11
+ - Supports standard SQL queries
12
+ - In-memory database for quick data access
13
+ - Robust error handling and validation
14
+
15
+ ## Usage
16
+
17
+ ```javascript
18
+ import { executeSql } from 'squirreling'
19
+
20
+ const data = [
21
+ { id: 1, name: 'Alice' },
22
+ { id: 2, name: 'Bob' },
23
+ ]
24
+
25
+ const result = executeSql(data, 'SELECT UPPER(name) AS name_upper FROM users')
26
+ console.log(result)
27
+ // Output: [ { name_upper: 'ALICE' }, { name_upper: 'BOB' } ]
28
+ ```
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "squirreling",
3
+ "version": "0.1.0",
4
+ "author": "Hyperparam",
5
+ "homepage": "https://hyperparam.app",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/hyparam/squirrel.git"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "types": "src/index.d.ts",
14
+ "main": "src/index.js",
15
+ "export": {
16
+ ".": {
17
+ "import": "./src/index.js",
18
+ "types": "./src/index.d.ts"
19
+ }
20
+ },
21
+ "files": [
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "coverage": "vitest run --coverage --coverage.include=src",
26
+ "lint": "eslint",
27
+ "lint:fix": "eslint --fix",
28
+ "test": "vitest run"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "24.10.1",
32
+ "@vitest/coverage-v8": "4.0.13",
33
+ "eslint": "9.39.1",
34
+ "eslint-plugin-jsdoc": "61.4.0",
35
+ "typescript": "5.9.3",
36
+ "vitest": "4.0.13"
37
+ }
38
+ }
@@ -0,0 +1,73 @@
1
+
2
+ /**
3
+ * Evaluates an aggregate function over a set of rows
4
+ *
5
+ * @import { AggregateColumn, Row } from '../types.js'
6
+ * @param {AggregateColumn} col - aggregate column definition
7
+ * @param {Row[]} rows - rows to aggregate
8
+ * @returns {number | null} aggregated result
9
+ */
10
+ export function evaluateAggregate(col, rows) {
11
+ const { arg, func } = col
12
+
13
+ if (func === 'COUNT') {
14
+ if (arg.kind === 'star') return rows.length
15
+ const field = arg.column
16
+ let count = 0
17
+ for (let i = 0; i < rows.length; i += 1) {
18
+ const v = rows[i][field]
19
+ if (v !== null && v !== undefined) {
20
+ count += 1
21
+ }
22
+ }
23
+ return count
24
+ }
25
+
26
+ if (func === 'SUM' || func === 'AVG' || func === 'MIN' || func === 'MAX') {
27
+ if (arg.kind === 'star') {
28
+ throw new Error(func + '(*) is not supported, use a column name')
29
+ }
30
+ const field = arg.column
31
+ let sum = 0
32
+ let count = 0
33
+ /** @type {number | null} */
34
+ let min = null
35
+ /** @type {number | null} */
36
+ let max = null
37
+
38
+ for (let i = 0; i < rows.length; i += 1) {
39
+ const raw = rows[i][field]
40
+ if (raw == null) continue
41
+ const num = Number(raw)
42
+ if (!Number.isFinite(num)) continue
43
+
44
+ if (count === 0) {
45
+ min = num
46
+ max = num
47
+ } else {
48
+ if (min == null || num < min) min = num
49
+ if (max == null || num > max) max = num
50
+ }
51
+ sum += num
52
+ count += 1
53
+ }
54
+
55
+ if (func === 'SUM') return sum
56
+ if (func === 'AVG') return count === 0 ? null : sum / count
57
+ if (func === 'MIN') return min
58
+ if (func === 'MAX') return max
59
+ }
60
+
61
+ throw new Error('Unsupported aggregate function ' + func)
62
+ }
63
+
64
+ /**
65
+ * 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")
68
+ */
69
+ export function defaultAggregateAlias(col) {
70
+ const base = col.func.toLowerCase()
71
+ if (col.arg.kind === 'star') return base + '_all'
72
+ return base + '_' + col.arg.column
73
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @import { FunctionColumn, FunctionNode, OrderByItem, Row, SelectStatement, SqlPrimitive } from '../types.js'
3
+ */
4
+
5
+ import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
6
+ import { evaluateExpr } from './expression.js'
7
+ import { parseSql } from '../parse/parse.js'
8
+
9
+ /**
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
14
+ */
15
+ export function executeSql(rows, sql) {
16
+ const select = parseSql(sql)
17
+ return evaluateSelectAst(select, rows)
18
+ }
19
+
20
+ /**
21
+ * 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")
24
+ */
25
+ function defaultFunctionAlias(col) {
26
+ const base = col.func.toLowerCase()
27
+ // Try to extract column names from identifier arguments
28
+ const columnNames = col.args
29
+ .filter(arg => arg.type === 'identifier')
30
+ .map(arg => arg.name)
31
+ if (columnNames.length > 0) {
32
+ return base + '_' + columnNames.join('_')
33
+ }
34
+ return base
35
+ }
36
+
37
+ /**
38
+ * 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
41
+ */
42
+ function stableRowKey(row) {
43
+ const keys = Object.keys(row).sort()
44
+ /** @type {string[]} */
45
+ const parts = []
46
+ for (const k of keys) {
47
+ const v = row[k]
48
+ parts.push(k + ':' + JSON.stringify(v))
49
+ }
50
+ return parts.join('|')
51
+ }
52
+
53
+ /**
54
+ * 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
58
+ */
59
+ function compareValues(a, b) {
60
+ if (a === b) return 0
61
+ if (a == null) return -1
62
+ if (b == null) return 1
63
+
64
+ if (typeof a === 'number' && typeof b === 'number') {
65
+ if (a < b) return -1
66
+ if (a > b) return 1
67
+ return 0
68
+ }
69
+
70
+ const as = String(a)
71
+ const bs = String(b)
72
+ if (as < bs) return -1
73
+ if (as > bs) return 1
74
+ return 0
75
+ }
76
+
77
+ /**
78
+ * Applies DISTINCT filtering to remove duplicate rows
79
+ * @param {Row[]} rows - The input rows
80
+ * @param {boolean} distinct - Whether to apply deduplication
81
+ * @returns {Row[]} The deduplicated rows
82
+ */
83
+ function applyDistinct(rows, distinct) {
84
+ if (!distinct) return rows
85
+ /** @type {Set<string>} */
86
+ const seen = new Set()
87
+ /** @type {Row[]} */
88
+ const result = []
89
+ for (const row of rows) {
90
+ const key = stableRowKey(row)
91
+ if (seen.has(key)) continue
92
+ seen.add(key)
93
+ result.push(row)
94
+ }
95
+ return result
96
+ }
97
+
98
+ /**
99
+ * 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
103
+ */
104
+ function applyOrderBy(rows, orderBy) {
105
+ if (!orderBy || orderBy.length === 0) return rows
106
+
107
+ const sorted = rows.slice()
108
+
109
+ sorted.sort((a, b) => {
110
+ for (const term of orderBy) {
111
+ const dir = term.direction
112
+ const av = evaluateExpr(term.expr, a)
113
+ const bv = evaluateExpr(term.expr, b)
114
+ const cmp = compareValues(av, bv)
115
+ if (cmp !== 0) {
116
+ return dir === 'DESC' ? -cmp : cmp
117
+ }
118
+ }
119
+ return 0
120
+ })
121
+
122
+ return sorted
123
+ }
124
+
125
+ /**
126
+ * 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
130
+ */
131
+ function evaluateSelectAst(select, rows) {
132
+ // Check for unsupported JOIN operations
133
+ if (select.joins.length) {
134
+ throw new Error('JOIN is not supported')
135
+ }
136
+
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
+ }
146
+ }
147
+ working = filtered
148
+ }
149
+
150
+ const hasAggregate = select.columns.some(col => col.kind === 'aggregate')
151
+ const useGrouping = hasAggregate || select.groupBy?.length > 0
152
+
153
+ /** @type {Row[]} */
154
+ const projected = []
155
+
156
+ if (useGrouping) {
157
+ /** @type {Row[][]} */
158
+ const groups = []
159
+
160
+ if (select.groupBy?.length) {
161
+ /** @type {Map<string, Row[]>} */
162
+ const map = new Map()
163
+ for (const row of working) {
164
+ /** @type {string[]} */
165
+ const keyParts = []
166
+ for (const expr of select.groupBy) {
167
+ const v = evaluateExpr(expr, row)
168
+ keyParts.push(JSON.stringify(v))
169
+ }
170
+ const key = keyParts.join('|')
171
+ let group = map.get(key)
172
+ if (!group) {
173
+ group = []
174
+ map.set(key, group)
175
+ groups.push(group)
176
+ }
177
+ group.push(row)
178
+ }
179
+ } else {
180
+ groups.push(working)
181
+ }
182
+
183
+ const hasStar = select.columns.some(col => col.kind === 'star')
184
+ if (hasStar && hasAggregate) {
185
+ throw new Error('SELECT * with aggregate functions is not supported in this implementation')
186
+ }
187
+
188
+ for (const group of groups) {
189
+ /** @type {Row} */
190
+ const resultRow = {}
191
+ for (const col of select.columns) {
192
+ 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]
197
+ }
198
+ continue
199
+ }
200
+
201
+ if (col.kind === 'column') {
202
+ const name = col.column
203
+ const alias = col.alias ?? name
204
+ // 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
206
+ continue
207
+ }
208
+
209
+ if (col.kind === 'function') {
210
+ // Evaluate function on the first row of the group
211
+ /** @type {FunctionNode} */
212
+ const funcNode = { type: 'function', name: col.func, args: col.args }
213
+ const alias = col.alias ?? defaultFunctionAlias(col)
214
+ const value = group.length > 0 ? evaluateExpr(funcNode, group[0]) : undefined
215
+ resultRow[alias] = value
216
+ continue
217
+ }
218
+
219
+ if (col.kind === 'aggregate') {
220
+ const alias = col.alias ?? defaultAggregateAlias(col)
221
+ const value = evaluateAggregate(col, group)
222
+ resultRow[alias] = value
223
+ continue
224
+ }
225
+
226
+ if (col.kind === 'operation') {
227
+ const alias = col.alias ?? 'expr'
228
+ const value = group.length > 0 ? evaluateExpr(col.expr, group[0]) : undefined
229
+ resultRow[alias] = value
230
+ continue
231
+ }
232
+ }
233
+ projected.push(resultRow)
234
+ }
235
+ } else {
236
+ for (const row of working) {
237
+ /** @type {Row} */
238
+ const outRow = {}
239
+ for (const col of select.columns) {
240
+ if (col.kind === 'star') {
241
+ const keys = Object.keys(row)
242
+ for (const key of keys) {
243
+ outRow[key] = row[key]
244
+ }
245
+ } else if (col.kind === 'column') {
246
+ const name = col.column
247
+ const alias = col.alias ?? name
248
+ outRow[alias] = row[name]
249
+ } else if (col.kind === 'function') {
250
+ /** @type {FunctionNode} */
251
+ const funcNode = { type: 'function', name: col.func, args: col.args }
252
+ const value = evaluateExpr(funcNode, row)
253
+ const alias = col.alias ?? defaultFunctionAlias(col)
254
+ outRow[alias] = value
255
+ } else if (col.kind === 'operation') {
256
+ const alias = col.alias ?? 'expr'
257
+ const value = evaluateExpr(col.expr, row)
258
+ outRow[alias] = value
259
+ } else if (col.kind === 'aggregate') {
260
+ throw new Error(
261
+ 'Aggregate functions require GROUP BY or will act on the whole dataset; add GROUP BY or remove aggregates'
262
+ )
263
+ }
264
+ }
265
+ projected.push(outRow)
266
+ }
267
+ }
268
+
269
+ let result = projected
270
+
271
+ result = applyDistinct(result, select.distinct)
272
+ result = applyOrderBy(result, select.orderBy)
273
+
274
+ if (typeof select.offset === 'number' && select.offset > 0) {
275
+ result = result.slice(select.offset)
276
+ }
277
+ if (typeof select.limit === 'number') {
278
+ result = result.slice(0, select.limit)
279
+ }
280
+
281
+ return result
282
+ }
@@ -0,0 +1,177 @@
1
+
2
+ /**
3
+ * Evaluates an expression node against a row of data
4
+ *
5
+ * @import { ExprNode, Row, SqlPrimitive } from '../types.js'
6
+ * @param {ExprNode} node - The expression node to evaluate
7
+ * @param {Row} row - The data row to evaluate against
8
+ * @returns {SqlPrimitive} The result of the evaluation
9
+ */
10
+ export function evaluateExpr(node, row) {
11
+ if (node.type === 'literal') {
12
+ return node.value
13
+ }
14
+
15
+ if (node.type === 'identifier') {
16
+ return row[node.name]
17
+ }
18
+
19
+ if (node.type === 'unary') {
20
+ if (node.op === 'NOT') {
21
+ return !evaluateExpr(node.argument, row)
22
+ }
23
+ if (node.op === 'IS NULL') {
24
+ return evaluateExpr(node.argument, row) == null
25
+ }
26
+ if (node.op === 'IS NOT NULL') {
27
+ return evaluateExpr(node.argument, row) != null
28
+ }
29
+ if (node.op === '-') {
30
+ const val = evaluateExpr(node.argument, row)
31
+ if (val == null) return null
32
+ return -Number(val)
33
+ }
34
+ }
35
+
36
+ if (node.type === 'binary') {
37
+ if (node.op === 'AND') {
38
+ const leftVal = evaluateExpr(node.left, row)
39
+ if (!leftVal) return false
40
+ return Boolean(evaluateExpr(node.right, row))
41
+ }
42
+
43
+ if (node.op === 'OR') {
44
+ const leftVal = evaluateExpr(node.left, row)
45
+ if (leftVal) return true
46
+ return Boolean(evaluateExpr(node.right, row))
47
+ }
48
+
49
+ const left = evaluateExpr(node.left, row)
50
+ const right = evaluateExpr(node.right, row)
51
+
52
+ // In SQL, NULL comparisons with =, !=, <> always return false (unknown)
53
+ // You must use IS NULL or IS NOT NULL to check for NULL
54
+ if (left == null || right == null) {
55
+ if (node.op === '=' || node.op === '!=' || node.op === '<>') {
56
+ return false
57
+ }
58
+ }
59
+
60
+ if (node.op === '=') return left === right
61
+ if (node.op === '!=' || node.op === '<>') return left !== right
62
+ if (node.op === '<') return left < right
63
+ if (node.op === '>') return left > right
64
+ if (node.op === '<=') return left <= right
65
+ if (node.op === '>=') return left >= right
66
+
67
+ if (node.op === 'LIKE') {
68
+ const str = String(left)
69
+ const pattern = String(right)
70
+ // Convert SQL LIKE pattern to regex
71
+ // % matches zero or more characters
72
+ // _ matches exactly one character
73
+ const regexPattern = pattern
74
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
75
+ .replace(/%/g, '.*') // Replace % with .*
76
+ .replace(/_/g, '.') // Replace _ with .
77
+ const regex = new RegExp('^' + regexPattern + '$', 'i')
78
+ return regex.test(str)
79
+ }
80
+ }
81
+
82
+ // Function calls
83
+ if (node.type === 'function') {
84
+ const funcName = node.name.toUpperCase()
85
+ const args = node.args.map(arg => evaluateExpr(arg, row))
86
+
87
+ if (funcName === 'UPPER') {
88
+ if (args.length !== 1) throw new Error('UPPER requires exactly 1 argument')
89
+ const val = args[0]
90
+ if (val == null) return null
91
+ return String(val).toUpperCase()
92
+ }
93
+
94
+ if (funcName === 'LOWER') {
95
+ if (args.length !== 1) throw new Error('LOWER requires exactly 1 argument')
96
+ const val = args[0]
97
+ if (val == null) return null
98
+ return String(val).toLowerCase()
99
+ }
100
+
101
+ if (funcName === 'CONCAT') {
102
+ if (args.length < 1) throw new Error('CONCAT requires at least 1 argument')
103
+ // SQL CONCAT returns NULL if any argument is NULL
104
+ for (let i = 0; i < args.length; i += 1) {
105
+ if (args[i] == null) return null
106
+ }
107
+ return args.map(a => String(a)).join('')
108
+ }
109
+
110
+ if (funcName === 'LENGTH') {
111
+ if (args.length !== 1) throw new Error('LENGTH requires exactly 1 argument')
112
+ const val = args[0]
113
+ if (val == null) return null
114
+ return String(val).length
115
+ }
116
+
117
+ if (funcName === 'SUBSTRING') {
118
+ if (args.length < 2 || args.length > 3) {
119
+ throw new Error('SUBSTRING requires 2 or 3 arguments')
120
+ }
121
+ const str = args[0]
122
+ if (str == null) return null
123
+ const strVal = String(str)
124
+ const start = Number(args[1])
125
+ if (!Number.isInteger(start) || start < 1) {
126
+ throw new Error('SUBSTRING start position must be a positive integer')
127
+ }
128
+ // SQL uses 1-based indexing
129
+ const startIdx = start - 1
130
+ if (args.length === 3) {
131
+ const len = Number(args[2])
132
+ if (!Number.isInteger(len) || len < 0) {
133
+ throw new Error('SUBSTRING length must be a non-negative integer')
134
+ }
135
+ return strVal.substring(startIdx, startIdx + len)
136
+ }
137
+ return strVal.substring(startIdx)
138
+ }
139
+
140
+ if (funcName === 'TRIM') {
141
+ if (args.length !== 1) throw new Error('TRIM requires exactly 1 argument')
142
+ const val = args[0]
143
+ if (val == null) return null
144
+ return String(val).trim()
145
+ }
146
+
147
+ throw new Error('Unsupported function ' + funcName)
148
+ }
149
+
150
+ if (node.type === 'cast') {
151
+ const val = evaluateExpr(node.expr, row)
152
+ if (val == null) return null
153
+ const toType = node.toType.toUpperCase()
154
+ if (toType === 'INTEGER' || toType === 'INT') {
155
+ const num = Number(val)
156
+ if (isNaN(num)) return null
157
+ return Math.trunc(num)
158
+ }
159
+ if (toType === 'BIGINT') {
160
+ return BigInt(val)
161
+ }
162
+ if (toType === 'FLOAT' || toType === 'REAL' || toType === 'DOUBLE') {
163
+ const num = Number(val)
164
+ if (isNaN(num)) return null
165
+ return num
166
+ }
167
+ if (toType === 'TEXT' || toType === 'STRING') {
168
+ return String(val)
169
+ }
170
+ if (toType === 'BOOLEAN' || toType === 'BOOL') {
171
+ return Boolean(val)
172
+ }
173
+ throw new Error('Unsupported CAST to type ' + node.toType)
174
+ }
175
+
176
+ throw new Error('Unknown expression node type ' + node.type)
177
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Row, SelectStatement } from './types.js'
2
+
3
+ /**
4
+ * Executes a SQL SELECT query against an array of data rows
5
+ *
6
+ * @param rows - data rows to query as a list of objects
7
+ * @param sql - SQL query string
8
+ * @returns rows matching the query
9
+ */
10
+ export function executeSql(rows: Row[], sql: string): Row[]
11
+
12
+ /**
13
+ * Parses a SQL query string into an abstract syntax tree
14
+ *
15
+ * @param sql - SQL query string to parse
16
+ * @returns parsed SQL select statement
17
+ */
18
+ export function parseSql(sql: string): SelectStatement
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { executeSql } from './execute/execute.js'
2
+ export { parseSql } from './parse/parse.js'