squirreling 0.4.6 → 0.4.7

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
@@ -67,3 +67,14 @@ const allUsers: Record<string, SqlPrimitive>[] = await collect(executeSql({
67
67
  }))
68
68
  console.log(allUsers)
69
69
  ```
70
+
71
+ ## Supported SQL Features
72
+
73
+ - `SELECT` statements with `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
74
+ - Subqueries in `SELECT`, `FROM`, and `WHERE` clauses
75
+ - `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`
76
+ - `GROUP BY` and `HAVING` clauses
77
+ - Aggregate functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
78
+ - String functions: `CONCAT`, `SUBSTRING`, `LENGTH`, `UPPER`, `LOWER`
79
+ - Json functions: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
80
+ - Basic expressions and arithmetic operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -1,15 +1,15 @@
1
1
  import { evaluateExpr } from './expression.js'
2
- import { defaultDerivedAlias } from './utils.js'
2
+ import { defaultDerivedAlias, stringify } from './utils.js'
3
3
 
4
4
  /**
5
5
  * Evaluates an aggregate function over a set of rows
6
6
  *
7
- * @import { AggregateColumn, AsyncDataSource, ExprNode, AsyncRow } from '../types.js'
7
+ * @import { AggregateColumn, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
8
8
  * @param {Object} options
9
9
  * @param {AggregateColumn} options.col - aggregate column definition
10
10
  * @param {AsyncRow[]} options.rows - rows to aggregate
11
11
  * @param {Record<string, AsyncDataSource>} options.tables
12
- * @returns {Promise<number | null>} aggregated result
12
+ * @returns {Promise<SqlPrimitive>} aggregated result
13
13
  */
14
14
  export async function evaluateAggregate({ col, rows, tables }) {
15
15
  const { arg, func } = col
@@ -70,6 +70,31 @@ export async function evaluateAggregate({ col, rows, tables }) {
70
70
  if (func === 'MAX') return max
71
71
  }
72
72
 
73
+ if (func === 'JSON_ARRAYAGG') {
74
+ if (arg.kind === 'star') {
75
+ throw new Error('JSON_ARRAYAGG(*) is not supported, use a column name or expression')
76
+ }
77
+ /** @type {SqlPrimitive[]} */
78
+ const values = []
79
+ if (arg.quantifier === 'distinct') {
80
+ const seen = new Set()
81
+ for (const row of rows) {
82
+ const v = await evaluateExpr({ node: arg.expr, row, tables })
83
+ const key = stringify(v)
84
+ if (!seen.has(key)) {
85
+ seen.add(key)
86
+ values.push(v)
87
+ }
88
+ }
89
+ } else {
90
+ for (const row of rows) {
91
+ const v = await evaluateExpr({ node: arg.expr, row, tables })
92
+ values.push(v)
93
+ }
94
+ }
95
+ return values
96
+ }
97
+
73
98
  throw new Error('Unsupported aggregate function ' + func)
74
99
  }
75
100
 
@@ -1,14 +1,14 @@
1
1
  import { generatorSource, memorySource } from '../backend/dataSource.js'
2
2
  import { parseSql } from '../parse/parse.js'
3
3
  import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
4
+ import { extractColumns } from './columns.js'
4
5
  import { evaluateExpr } from './expression.js'
5
6
  import { evaluateHavingExpr } from './having.js'
6
7
  import { executeJoins } from './join.js'
7
- import { compareForTerm, defaultDerivedAlias } from './utils.js'
8
- import { extractColumns } from './columns.js'
8
+ import { compareForTerm, defaultDerivedAlias, stringify } from './utils.js'
9
9
 
10
10
  /**
11
- * @import { AsyncDataSource, AsyncRow, ExecuteSqlOptions, ExprNode, OrderByItem, QueryHints, SelectStatement, SqlPrimitive } from '../types.js'
11
+ * @import { AsyncDataSource, AsyncRow, ExecuteSqlOptions, OrderByItem, QueryHints, SelectStatement, SqlPrimitive } from '../types.js'
12
12
  */
13
13
 
14
14
  /**
@@ -85,7 +85,7 @@ async function stableRowKey(row) {
85
85
  const parts = []
86
86
  for (const k of keys) {
87
87
  const v = await row[k]()
88
- parts.push(k + ':' + JSON.stringify(v))
88
+ parts.push(k + ':' + stringify(v))
89
89
  }
90
90
  return parts.join('|')
91
91
  }
@@ -358,7 +358,7 @@ async function* evaluateBuffered(select, dataSource, tables, hasAggregate, useGr
358
358
  const keyParts = []
359
359
  for (const expr of select.groupBy) {
360
360
  const v = await evaluateExpr({ node: expr, row, tables })
361
- keyParts.push(JSON.stringify(v))
361
+ keyParts.push(stringify(v))
362
362
  }
363
363
  const key = keyParts.join('|')
364
364
  let group = map.get(key)
@@ -1,5 +1,5 @@
1
1
  import { executeSelect } from './execute.js'
2
- import { applyBinaryOp } from './utils.js'
2
+ import { applyBinaryOp, stringify } from './utils.js'
3
3
 
4
4
  /**
5
5
  * @import { ExprNode, AsyncRow, SqlPrimitive, AsyncDataSource } from '../types.js'
@@ -83,6 +83,7 @@ export async function evaluateExpr({ node, row, tables }) {
83
83
  // Function calls
84
84
  if (node.type === 'function') {
85
85
  const funcName = node.name.toUpperCase()
86
+ /** @type {SqlPrimitive[]} */
86
87
  const args = await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, tables })))
87
88
 
88
89
  if (funcName === 'UPPER') {
@@ -102,8 +103,9 @@ export async function evaluateExpr({ node, row, tables }) {
102
103
  if (funcName === 'CONCAT') {
103
104
  if (args.length < 1) throw new Error('CONCAT requires at least 1 argument')
104
105
  // SQL CONCAT returns NULL if any argument is NULL
105
- for (let i = 0; i < args.length; i += 1) {
106
- if (args[i] == null) return null
106
+ if (args.some(a => a == null)) return null
107
+ if (args.some(a => typeof a === 'object')) {
108
+ throw new Error('CONCAT does not support object arguments')
107
109
  }
108
110
  return args.map(a => String(a)).join('')
109
111
  }
@@ -160,6 +162,67 @@ export async function evaluateExpr({ node, row, tables }) {
160
162
  return Math.random()
161
163
  }
162
164
 
165
+ if (funcName === 'JSON_OBJECT') {
166
+ if (args.length % 2 !== 0) {
167
+ throw new Error('JSON_OBJECT requires an even number of arguments (key-value pairs)')
168
+ }
169
+ /** @type {Record<string, SqlPrimitive>} */
170
+ const result = {}
171
+ for (let i = 0; i < args.length; i += 2) {
172
+ const key = args[i]
173
+ const value = args[i + 1]
174
+ if (key == null) {
175
+ throw new Error('JSON_OBJECT: key cannot be null')
176
+ }
177
+ result[String(key)] = value
178
+ }
179
+ return result
180
+ }
181
+
182
+ if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY') {
183
+ if (args.length !== 2) throw new Error(`${funcName} requires exactly 2 arguments`)
184
+ let jsonArg = args[0]
185
+ const pathArg = args[1]
186
+ if (jsonArg == null || pathArg == null) return null
187
+
188
+ // Parse JSON if string, otherwise use directly
189
+ if (typeof jsonArg === 'string') {
190
+ try {
191
+ jsonArg = JSON.parse(jsonArg)
192
+ } catch {
193
+ throw new Error(`${funcName}: invalid JSON string`)
194
+ }
195
+ }
196
+ if (typeof jsonArg !== 'object') {
197
+ throw new Error(`${funcName}: first argument must be JSON string or object`)
198
+ }
199
+
200
+ // Parse path ("$.foo.bar[0].baz" or "foo.bar[0]")
201
+ const path = String(pathArg)
202
+ const normalizedPath = path.startsWith('$') ? path.slice(1) : path
203
+
204
+ // Navigate the path
205
+ let current = jsonArg
206
+ const segments = normalizedPath.match(/\.?([^.[]+)|\[(\d+)\]/g) || []
207
+ for (const segment of segments) {
208
+ if (current == null) return null
209
+ if (segment.startsWith('[')) {
210
+ // Array index access
211
+ const index = parseInt(segment.slice(1, -1), 10)
212
+ if (!Array.isArray(current)) return null
213
+ current = current[index]
214
+ } else {
215
+ // Property access
216
+ const key = segment.startsWith('.') ? segment.slice(1) : segment
217
+ if (typeof current !== 'object' || Array.isArray(current)) return null
218
+ current = current[key]
219
+ }
220
+ }
221
+
222
+ if (current == null) return null
223
+ return current
224
+ }
225
+
163
226
  throw new Error('Unsupported function ' + funcName)
164
227
  }
165
228
 
@@ -167,6 +230,12 @@ export async function evaluateExpr({ node, row, tables }) {
167
230
  const val = await evaluateExpr({ node: node.expr, row, tables })
168
231
  if (val == null) return null
169
232
  const toType = node.toType.toUpperCase()
233
+ if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
234
+ if (typeof val === 'object') return stringify(val)
235
+ return String(val)
236
+ }
237
+ // Can only cast primitives to other primitive types
238
+ if (typeof val === 'object') throw new Error(`Cannot CAST object to type ${node.toType}`)
170
239
  if (toType === 'INTEGER' || toType === 'INT') {
171
240
  const num = Number(val)
172
241
  if (isNaN(num)) return null
@@ -180,9 +249,6 @@ export async function evaluateExpr({ node, row, tables }) {
180
249
  if (isNaN(num)) return null
181
250
  return num
182
251
  }
183
- if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
184
- return String(val)
185
- }
186
252
  if (toType === 'BOOLEAN' || toType === 'BOOL') {
187
253
  return Boolean(val)
188
254
  }
@@ -41,7 +41,7 @@ export async function evaluateHavingExpr(expr, row, group, tables) {
41
41
  }
42
42
 
43
43
  const right = await evaluateHavingValue(expr.right, context, group, tables)
44
- return applyBinaryOp(expr.op, left, right)
44
+ return Boolean(applyBinaryOp(expr.op, left, right))
45
45
  }
46
46
 
47
47
  if (expr.type === 'unary') {
@@ -1,4 +1,5 @@
1
1
  import { evaluateExpr } from './expression.js'
2
+ import { stringify } from './utils.js'
2
3
 
3
4
  /**
4
5
  * @import { AsyncRow, AsyncDataSource, JoinClause, ExprNode } from '../types.js'
@@ -258,8 +259,7 @@ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tab
258
259
  for (const rightRow of rightRows) {
259
260
  const keyValue = await evaluateExpr({ node: keys.rightKey, row: rightRow, tables })
260
261
  if (keyValue == null) continue // NULL keys never match
261
- const keyStr = JSON.stringify(keyValue)
262
-
262
+ const keyStr = stringify(keyValue)
263
263
  let bucket = hashMap.get(keyStr)
264
264
  if (!bucket) {
265
265
  bucket = []
@@ -283,7 +283,7 @@ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tab
283
283
  }
284
284
 
285
285
  const keyValue = await evaluateExpr({ node: keys.leftKey, row: leftRow, tables })
286
- const keyStr = JSON.stringify(keyValue)
286
+ const keyStr = stringify(keyValue)
287
287
 
288
288
  const matchingRightRows = hashMap.get(keyStr)
289
289
 
@@ -3,14 +3,27 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Compares two values with the given operator, handling nulls according to SQL semantics
6
+ * Applies a binary operator to two values, handling nulls according to SQL semantics
7
7
  *
8
8
  * @param {BinaryOp} op
9
9
  * @param {SqlPrimitive} a
10
10
  * @param {SqlPrimitive} b
11
- * @returns {boolean}
11
+ * @returns {SqlPrimitive}
12
12
  */
13
13
  export function applyBinaryOp(op, a, b) {
14
+ // Arithmetic operators return null if either operand is null
15
+ if (op === '+' || op === '-' || op === '*' || op === '/' || op === '%') {
16
+ if (a == null || b == null) return null
17
+ const numA = Number(a)
18
+ const numB = Number(b)
19
+ if (op === '+') return numA + numB
20
+ if (op === '-') return numA - numB
21
+ if (op === '*') return numA * numB
22
+ if (op === '/') return numB === 0 ? null : numA / numB
23
+ if (op === '%') return numB === 0 ? null : numA % numB
24
+ }
25
+
26
+ // Comparison and logical operators
14
27
  if (a == null || b == null) {
15
28
  return false
16
29
  }
@@ -33,6 +46,8 @@ export function applyBinaryOp(op, a, b) {
33
46
  const regex = new RegExp(`^${regexPattern}$`, 'i')
34
47
  return regex.test(str)
35
48
  }
49
+
50
+ return null
36
51
  }
37
52
 
38
53
  /**
@@ -121,3 +136,17 @@ export function defaultDerivedAlias(expr) {
121
136
  }
122
137
  return 'expr'
123
138
  }
139
+
140
+ /**
141
+ * @param {SqlPrimitive} value
142
+ * @returns {string}
143
+ */
144
+ export function stringify(value) {
145
+ if (value == null) return 'NULL'
146
+ return JSON.stringify(value, (_, val) => {
147
+ if (typeof val === 'bigint') {
148
+ return val <= Number.MAX_SAFE_INTEGER ? Number(val) : val.toString()
149
+ }
150
+ return val
151
+ })
152
+ }
@@ -1,5 +1,5 @@
1
1
  import { isBinaryOp } from '../validation.js'
2
- import { parseExpression, parsePrimary, parseSubquery } from './expression.js'
2
+ import { parseAdditive, parseExpression, parseSubquery } from './expression.js'
3
3
  import { consume, current, expect, match, peekToken } from './state.js'
4
4
 
5
5
  /**
@@ -11,7 +11,7 @@ import { consume, current, expect, match, peekToken } from './state.js'
11
11
  * @returns {ExprNode}
12
12
  */
13
13
  export function parseComparison(state) {
14
- const left = parsePrimary(state)
14
+ const left = parseAdditive(state)
15
15
  const tok = current(state)
16
16
 
17
17
  // IS [NOT] NULL
@@ -41,7 +41,7 @@ export function parseComparison(state) {
41
41
  if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
42
42
  consume(state) // NOT
43
43
  consume(state) // LIKE
44
- const right = parsePrimary(state)
44
+ const right = parseAdditive(state)
45
45
  return {
46
46
  type: 'unary',
47
47
  op: 'NOT',
@@ -57,7 +57,7 @@ export function parseComparison(state) {
57
57
 
58
58
  if (tok.type === 'keyword' && tok.value === 'LIKE') {
59
59
  consume(state)
60
- const right = parsePrimary(state)
60
+ const right = parseAdditive(state)
61
61
  return {
62
62
  type: 'binary',
63
63
  op: 'LIKE',
@@ -72,9 +72,9 @@ export function parseComparison(state) {
72
72
  if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
73
73
  consume(state) // NOT
74
74
  consume(state) // BETWEEN
75
- const lower = parsePrimary(state)
75
+ const lower = parseAdditive(state)
76
76
  expect(state, 'keyword', 'AND')
77
- const upper = parsePrimary(state)
77
+ const upper = parseAdditive(state)
78
78
  // NOT BETWEEN -> expr < lower OR expr > upper
79
79
  return {
80
80
  type: 'binary',
@@ -87,9 +87,9 @@ export function parseComparison(state) {
87
87
 
88
88
  if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
89
89
  consume(state)
90
- const lower = parsePrimary(state)
90
+ const lower = parseAdditive(state)
91
91
  expect(state, 'keyword', 'AND')
92
- const upper = parsePrimary(state)
92
+ const upper = parseAdditive(state)
93
93
  // BETWEEN -> expr >= lower AND expr <= upper
94
94
  return {
95
95
  type: 'binary',
@@ -186,7 +186,7 @@ export function parseComparison(state) {
186
186
 
187
187
  if (tok.type === 'operator' && isBinaryOp(tok.value)) {
188
188
  consume(state)
189
- const right = parsePrimary(state)
189
+ const right = parseAdditive(state)
190
190
  return {
191
191
  type: 'binary',
192
192
  op: tok.value,
@@ -272,6 +272,54 @@ function parseNot(state) {
272
272
  return parseComparison(state)
273
273
  }
274
274
 
275
+ /**
276
+ * @param {ParserState} state
277
+ * @returns {ExprNode}
278
+ */
279
+ export function parseAdditive(state) {
280
+ let node = parseMultiplicative(state)
281
+ while (true) {
282
+ const tok = current(state)
283
+ if (tok.type === 'operator' && (tok.value === '+' || tok.value === '-')) {
284
+ consume(state)
285
+ const right = parseMultiplicative(state)
286
+ node = {
287
+ type: 'binary',
288
+ op: tok.value,
289
+ left: node,
290
+ right,
291
+ }
292
+ } else {
293
+ break
294
+ }
295
+ }
296
+ return node
297
+ }
298
+
299
+ /**
300
+ * @param {ParserState} state
301
+ * @returns {ExprNode}
302
+ */
303
+ function parseMultiplicative(state) {
304
+ let node = parsePrimary(state)
305
+ while (true) {
306
+ const tok = current(state)
307
+ if (tok.type === 'operator' && (tok.value === '*' || tok.value === '/' || tok.value === '%')) {
308
+ consume(state)
309
+ const right = parsePrimary(state)
310
+ node = {
311
+ type: 'binary',
312
+ op: tok.value,
313
+ left: node,
314
+ right,
315
+ }
316
+ } else {
317
+ break
318
+ }
319
+ }
320
+ return node
321
+ }
322
+
275
323
  /**
276
324
  * Creates an ExprCursor adapter for the ParserState.
277
325
  *
package/src/types.d.ts CHANGED
@@ -27,7 +27,7 @@ export interface ExecuteSqlOptions {
27
27
  query: string
28
28
  }
29
29
 
30
- export type SqlPrimitive = string | number | bigint | boolean | null
30
+ export type SqlPrimitive = string | number | bigint | boolean | SqlPrimitive[] | Record<string, any> | null
31
31
 
32
32
  export interface SelectStatement {
33
33
  distinct: boolean
@@ -54,7 +54,9 @@ export interface FromSubquery {
54
54
  alias: string
55
55
  }
56
56
 
57
- export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp
57
+ export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
58
+
59
+ export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp | ArithmeticOp
58
60
 
59
61
  export type ComparisonOp = '=' | '!=' | '<>' | '<' | '>' | '<=' | '>='
60
62
 
@@ -146,9 +148,20 @@ export interface StarColumn {
146
148
  alias?: string
147
149
  }
148
150
 
149
- export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX'
150
-
151
- export type StringFunc = 'UPPER' | 'LOWER' | 'CONCAT' | 'LENGTH' | 'SUBSTRING' | 'SUBSTR' | 'TRIM' | 'REPLACE'
151
+ export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG'
152
+
153
+ export type StringFunc =
154
+ | 'UPPER'
155
+ | 'LOWER'
156
+ | 'CONCAT'
157
+ | 'LENGTH'
158
+ | 'SUBSTRING'
159
+ | 'SUBSTR'
160
+ | 'TRIM'
161
+ | 'REPLACE'
162
+ | 'JSON_VALUE'
163
+ | 'JSON_QUERY'
164
+ | 'JSON_OBJECT'
152
165
 
153
166
  export interface AggregateArgStar {
154
167
  kind: 'star'
package/src/validation.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * @returns {name is AggregateFunc}
6
6
  */
7
7
  export function isAggregateFunc(name) {
8
- return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(name)
8
+ return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG'].includes(name)
9
9
  }
10
10
 
11
11
  /**
@@ -13,7 +13,21 @@ export function isAggregateFunc(name) {
13
13
  * @returns {name is StringFunc}
14
14
  */
15
15
  export function isStringFunc(name) {
16
- return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE', 'RANDOM', 'RAND'].includes(name)
16
+ return [
17
+ 'UPPER',
18
+ 'LOWER',
19
+ 'CONCAT',
20
+ 'LENGTH',
21
+ 'SUBSTRING',
22
+ 'SUBSTR',
23
+ 'TRIM',
24
+ 'REPLACE',
25
+ 'RANDOM',
26
+ 'RAND',
27
+ 'JSON_VALUE',
28
+ 'JSON_QUERY',
29
+ 'JSON_OBJECT',
30
+ ].includes(name)
17
31
  }
18
32
 
19
33
  /**