squirreling 0.2.1 → 0.2.3
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 +1 -1
- package/package.json +2 -2
- package/src/execute/execute.js +11 -5
- package/src/execute/expression.js +16 -0
- package/src/execute/having.js +5 -3
- package/src/index.d.ts +1 -1
- package/src/parse/expression.js +58 -9
- package/src/parse/parse.js +41 -6
- package/src/parse/tokenize.js +7 -6
- package/src/types.d.ts +22 -2
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ const source = [
|
|
|
30
30
|
{ id: 2, name: 'Bob' },
|
|
31
31
|
]
|
|
32
32
|
|
|
33
|
-
const result = executeSql({ source,
|
|
33
|
+
const result = executeSql({ source, query: 'SELECT UPPER(name) AS name_upper FROM users' })
|
|
34
34
|
console.log(result)
|
|
35
35
|
// Output: [ { name_upper: 'ALICE' }, { name_upper: 'BOB' } ]
|
|
36
36
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Squirreling SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@types/node": "24.10.1",
|
|
41
41
|
"@vitest/coverage-v8": "4.0.13",
|
|
42
42
|
"eslint": "9.39.1",
|
|
43
|
-
"eslint-plugin-jsdoc": "61.4.
|
|
43
|
+
"eslint-plugin-jsdoc": "61.4.1",
|
|
44
44
|
"typescript": "5.9.3",
|
|
45
45
|
"vitest": "4.0.13"
|
|
46
46
|
}
|
package/src/execute/execute.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
|
|
6
6
|
import { evaluateExpr } from './expression.js'
|
|
7
|
-
import {
|
|
7
|
+
import { evaluateHavingExpr } from './having.js'
|
|
8
8
|
import { parseSql } from '../parse/parse.js'
|
|
9
9
|
import { createMemorySource, createRowAccessor } from '../backend/memory.js'
|
|
10
10
|
|
|
@@ -14,8 +14,8 @@ import { createMemorySource, createRowAccessor } from '../backend/memory.js'
|
|
|
14
14
|
* @param {ExecuteSqlOptions} options - the execution options
|
|
15
15
|
* @returns {Record<string, any>[]} the result rows matching the query
|
|
16
16
|
*/
|
|
17
|
-
export function executeSql({ source,
|
|
18
|
-
const select = parseSql(
|
|
17
|
+
export function executeSql({ source, query }) {
|
|
18
|
+
const select = parseSql(query)
|
|
19
19
|
const dataSource = Array.isArray(source) ? createMemorySource(source) : source
|
|
20
20
|
return evaluateSelectAst(select, dataSource)
|
|
21
21
|
}
|
|
@@ -142,6 +142,13 @@ function evaluateSelectAst(select, dataSource) {
|
|
|
142
142
|
throw new Error('JOIN is not supported')
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// Check for unsupported subquery in FROM clause
|
|
146
|
+
if (typeof select.from !== 'string') {
|
|
147
|
+
throw new Error('Subquery in FROM clause is not supported')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// SQL priority: from, where, group by, having, select, order by, offset, limit
|
|
151
|
+
|
|
145
152
|
// WHERE clause filtering
|
|
146
153
|
/** @type {RowSource[]} */
|
|
147
154
|
const working = []
|
|
@@ -244,8 +251,7 @@ function evaluateSelectAst(select, dataSource) {
|
|
|
244
251
|
if (select.having) {
|
|
245
252
|
// For HAVING, we need to evaluate aggregates in the context of the group
|
|
246
253
|
// Create a special row context that includes both the group data and aggregate values
|
|
247
|
-
|
|
248
|
-
if (!evaluateHavingExpr(select.having, havingContext, group)) {
|
|
254
|
+
if (!evaluateHavingExpr(select.having, resultRow, group)) {
|
|
249
255
|
continue
|
|
250
256
|
}
|
|
251
257
|
}
|
|
@@ -190,5 +190,21 @@ export function evaluateExpr(node, row) {
|
|
|
190
190
|
throw new Error('Unsupported CAST to type ' + node.toType)
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// IN and NOT IN with subqueries
|
|
194
|
+
if (node.type === 'in') {
|
|
195
|
+
throw new Error('WHERE IN with subqueries is not yet supported.')
|
|
196
|
+
}
|
|
197
|
+
if (node.type === 'not in') {
|
|
198
|
+
throw new Error('WHERE NOT IN with subqueries is not yet supported.')
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// EXISTS and NOT EXISTS with subqueries
|
|
202
|
+
if (node.type === 'exists') {
|
|
203
|
+
throw new Error('WHERE EXISTS with subqueries is not yet supported.')
|
|
204
|
+
}
|
|
205
|
+
if (node.type === 'not exists') {
|
|
206
|
+
throw new Error('WHERE NOT EXISTS with subqueries is not yet supported.')
|
|
207
|
+
}
|
|
208
|
+
|
|
193
209
|
throw new Error('Unknown expression node type ' + node.type)
|
|
194
210
|
}
|
package/src/execute/having.js
CHANGED
|
@@ -12,7 +12,7 @@ import { evaluateExpr } from './expression.js'
|
|
|
12
12
|
* @param {RowSource[]} group - the group of rows
|
|
13
13
|
* @returns {RowSource} a context row for HAVING evaluation
|
|
14
14
|
*/
|
|
15
|
-
|
|
15
|
+
function createHavingContext(resultRow, group) {
|
|
16
16
|
// Include the first row of the group (for GROUP BY columns)
|
|
17
17
|
const firstRow = group[0]
|
|
18
18
|
/** @type {Record<string, any>} */
|
|
@@ -41,11 +41,13 @@ export function createHavingContext(resultRow, group) {
|
|
|
41
41
|
* Evaluates a HAVING expression with support for aggregate functions
|
|
42
42
|
*
|
|
43
43
|
* @param {ExprNode} expr - the HAVING expression
|
|
44
|
-
* @param {
|
|
44
|
+
* @param {Record<string, any>} row - the aggregated result row
|
|
45
45
|
* @param {RowSource[]} group - the group of rows for re-evaluating aggregates
|
|
46
46
|
* @returns {boolean} whether the HAVING condition is satisfied
|
|
47
47
|
*/
|
|
48
|
-
export function evaluateHavingExpr(expr,
|
|
48
|
+
export function evaluateHavingExpr(expr, row, group) {
|
|
49
|
+
const context = createHavingContext(row, group)
|
|
50
|
+
|
|
49
51
|
// For HAVING, we need special handling of aggregate functions
|
|
50
52
|
// They need to be re-evaluated against the group
|
|
51
53
|
if (expr.type === 'function') {
|
package/src/index.d.ts
CHANGED
|
@@ -16,4 +16,4 @@ export function executeSql(options: ExecuteSqlOptions): Record<string, any>[]
|
|
|
16
16
|
* @param sql - SQL query string to parse
|
|
17
17
|
* @returns parsed SQL select statement
|
|
18
18
|
*/
|
|
19
|
-
export function parseSql(
|
|
19
|
+
export function parseSql(query: string): SelectStatement
|
package/src/parse/expression.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { ExprCursor, ExprNode, BinaryOp } from '../types.js'
|
|
2
|
+
* @import { ExprCursor, ExprNode, BinaryOp, SelectStatement } from '../types.js'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -111,6 +111,17 @@ export function parsePrimary(c) {
|
|
|
111
111
|
c.consume()
|
|
112
112
|
return { type: 'literal', value: null }
|
|
113
113
|
}
|
|
114
|
+
if (tok.value === 'EXISTS') {
|
|
115
|
+
c.consume() // EXISTS
|
|
116
|
+
if (!c.parseSubquery) {
|
|
117
|
+
throw new Error('Subquery parsing not available in this context')
|
|
118
|
+
}
|
|
119
|
+
const subquery = c.parseSubquery()
|
|
120
|
+
return {
|
|
121
|
+
type: 'exists',
|
|
122
|
+
subquery,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
if (tok.type === 'operator' && tok.value === '-') {
|
|
@@ -123,14 +134,8 @@ export function parsePrimary(c) {
|
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
tok.position +
|
|
129
|
-
': ' +
|
|
130
|
-
tok.type +
|
|
131
|
-
' ' +
|
|
132
|
-
tok.value
|
|
133
|
-
)
|
|
137
|
+
const found = tok.type === 'eof' ? 'end of query' : `"${tok.originalValue ?? tok.value}"`
|
|
138
|
+
throw new Error(`Expected expression but found ${found} at position ${tok.position}`)
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
/**
|
|
@@ -175,6 +180,19 @@ function parseAnd(c) {
|
|
|
175
180
|
*/
|
|
176
181
|
function parseNot(c) {
|
|
177
182
|
if (c.match('keyword', 'NOT')) {
|
|
183
|
+
// Check for NOT EXISTS
|
|
184
|
+
const nextTok = c.current()
|
|
185
|
+
if (nextTok.type === 'keyword' && nextTok.value === 'EXISTS') {
|
|
186
|
+
c.consume() // EXISTS
|
|
187
|
+
if (!c.parseSubquery) {
|
|
188
|
+
throw new Error('Subquery parsing not available in this context')
|
|
189
|
+
}
|
|
190
|
+
const subquery = c.parseSubquery()
|
|
191
|
+
return {
|
|
192
|
+
type: 'not exists',
|
|
193
|
+
subquery,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
178
196
|
const argument = parseNot(c)
|
|
179
197
|
return {
|
|
180
198
|
type: 'unary',
|
|
@@ -257,6 +275,37 @@ function parseComparison(c) {
|
|
|
257
275
|
}
|
|
258
276
|
}
|
|
259
277
|
|
|
278
|
+
// [NOT] IN
|
|
279
|
+
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
280
|
+
const nextTok = c.peek(1)
|
|
281
|
+
if (nextTok.type === 'keyword' && nextTok.value === 'IN') {
|
|
282
|
+
c.consume() // NOT
|
|
283
|
+
c.consume() // IN
|
|
284
|
+
if (!c.parseSubquery) {
|
|
285
|
+
throw new Error('Subquery parsing not available in this context')
|
|
286
|
+
}
|
|
287
|
+
const subquery = c.parseSubquery()
|
|
288
|
+
return {
|
|
289
|
+
type: 'not in',
|
|
290
|
+
expr: left,
|
|
291
|
+
subquery,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (tok.type === 'keyword' && tok.value === 'IN') {
|
|
297
|
+
c.consume() // IN
|
|
298
|
+
if (!c.parseSubquery) {
|
|
299
|
+
throw new Error('Subquery parsing not available in this context')
|
|
300
|
+
}
|
|
301
|
+
const subquery = c.parseSubquery()
|
|
302
|
+
return {
|
|
303
|
+
type: 'in',
|
|
304
|
+
expr: left,
|
|
305
|
+
subquery,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
260
309
|
if (tok.type === 'operator' && isComparisonOperator(tok.value)) {
|
|
261
310
|
c.consume()
|
|
262
311
|
const right = parsePrimary(c)
|
package/src/parse/parse.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @import { AggregateColumn, AggregateArg, AggregateFunc, ExprCursor, ExprNode, JoinClause, JoinType, OrderByItem, ParserState, SelectStatement, SelectColumn, StringFunc, Token, TokenType } from '../types.js'
|
|
2
|
+
* @import { AggregateColumn, AggregateArg, AggregateFunc, ExprCursor, ExprNode, FromSubquery, JoinClause, JoinType, OrderByItem, ParserState, SelectStatement, SelectColumn, StringFunc, Token, TokenType } from '../types.js'
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { tokenize } from './tokenize.js'
|
|
@@ -18,11 +18,11 @@ const RESERVED_AFTER_COLUMN = new Set([
|
|
|
18
18
|
])
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* @param {string}
|
|
21
|
+
* @param {string} query
|
|
22
22
|
* @returns {SelectStatement}
|
|
23
23
|
*/
|
|
24
|
-
export function parseSql(
|
|
25
|
-
const tokens = tokenize(
|
|
24
|
+
export function parseSql(query) {
|
|
25
|
+
const tokens = tokenize(query)
|
|
26
26
|
/** @type {ParserState} */
|
|
27
27
|
const state = { tokens, pos: 0 }
|
|
28
28
|
const select = parseSelectInternal(state)
|
|
@@ -123,6 +123,12 @@ function createExprCursor(state) {
|
|
|
123
123
|
match: (type, value) => match(state, type, value),
|
|
124
124
|
expect: (type, value) => expect(state, type, value),
|
|
125
125
|
expectIdentifier: () => expectIdentifier(state),
|
|
126
|
+
parseSubquery: () => {
|
|
127
|
+
expect(state, 'paren', '(')
|
|
128
|
+
const query = parseSelectInternal(state)
|
|
129
|
+
expect(state, 'paren', ')')
|
|
130
|
+
return query
|
|
131
|
+
},
|
|
126
132
|
}
|
|
127
133
|
}
|
|
128
134
|
|
|
@@ -384,6 +390,24 @@ function parseJoins(state) {
|
|
|
384
390
|
return joins
|
|
385
391
|
}
|
|
386
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Parses a subquery in parentheses with an alias
|
|
395
|
+
* @param {ParserState} state
|
|
396
|
+
* @returns {FromSubquery}
|
|
397
|
+
*/
|
|
398
|
+
function parseSubquery(state) {
|
|
399
|
+
expect(state, 'paren', '(')
|
|
400
|
+
const query = parseSelectInternal(state)
|
|
401
|
+
expect(state, 'paren', ')')
|
|
402
|
+
expect(state, 'keyword', 'AS')
|
|
403
|
+
const aliasTok = expectIdentifier(state)
|
|
404
|
+
return {
|
|
405
|
+
kind: 'subquery',
|
|
406
|
+
query,
|
|
407
|
+
alias: aliasTok.value,
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
387
411
|
/**
|
|
388
412
|
* @param {ParserState} state
|
|
389
413
|
* @returns {SelectStatement}
|
|
@@ -399,7 +423,17 @@ function parseSelectInternal(state) {
|
|
|
399
423
|
const columns = parseSelectList(state)
|
|
400
424
|
|
|
401
425
|
expect(state, 'keyword', 'FROM')
|
|
402
|
-
|
|
426
|
+
|
|
427
|
+
// Check if it's a subquery or table name
|
|
428
|
+
let from
|
|
429
|
+
const tok = current(state)
|
|
430
|
+
if (tok.type === 'paren' && tok.value === '(') {
|
|
431
|
+
// Subquery: SELECT * FROM (SELECT ...) AS alias
|
|
432
|
+
from = parseSubquery(state)
|
|
433
|
+
} else {
|
|
434
|
+
// Simple table name: SELECT * FROM users
|
|
435
|
+
from = expectIdentifier(state).value
|
|
436
|
+
}
|
|
403
437
|
|
|
404
438
|
// Parse JOIN clauses
|
|
405
439
|
const joins = parseJoins(state)
|
|
@@ -521,5 +555,6 @@ function parseError(state, expected) {
|
|
|
521
555
|
const tok = current(state)
|
|
522
556
|
const prevToken = state.tokens[state.pos - 1]
|
|
523
557
|
const after = prevToken ? ` after "${prevToken.originalValue ?? prevToken.value}"` : ''
|
|
524
|
-
|
|
558
|
+
const found = tok.type === 'eof' ? 'end of query' : `"${tok.originalValue ?? tok.value}"`
|
|
559
|
+
return new Error(`Expected ${expected}${after} but found ${found} at position ${tok.position}`)
|
|
525
560
|
}
|
package/src/parse/tokenize.js
CHANGED
|
@@ -25,6 +25,7 @@ const KEYWORDS = new Set([
|
|
|
25
25
|
'NULL',
|
|
26
26
|
'LIKE',
|
|
27
27
|
'IN',
|
|
28
|
+
'EXISTS',
|
|
28
29
|
'BETWEEN',
|
|
29
30
|
'CASE',
|
|
30
31
|
'WHEN',
|
|
@@ -122,11 +123,11 @@ export function tokenize(sql) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
if (isAlpha(peek())) {
|
|
125
|
-
throw new Error(
|
|
126
|
+
throw new Error(`Invalid number at position ${pos}: ${text}${peek()}`)
|
|
126
127
|
}
|
|
127
128
|
const num = parseFloat(text)
|
|
128
129
|
if (isNaN(num)) {
|
|
129
|
-
throw new Error(
|
|
130
|
+
throw new Error(`Invalid number at position ${pos}: ${text}`)
|
|
130
131
|
}
|
|
131
132
|
tokens.push({
|
|
132
133
|
type: 'number',
|
|
@@ -167,7 +168,7 @@ export function tokenize(sql) {
|
|
|
167
168
|
let text = ''
|
|
168
169
|
while (i <= length) {
|
|
169
170
|
if (i === length) {
|
|
170
|
-
throw new Error(
|
|
171
|
+
throw new Error(`Unterminated string literal starting at position ${pos}`)
|
|
171
172
|
}
|
|
172
173
|
const c = nextChar()
|
|
173
174
|
if (c === quote) {
|
|
@@ -195,7 +196,7 @@ export function tokenize(sql) {
|
|
|
195
196
|
let text = ''
|
|
196
197
|
while (i <= length) {
|
|
197
198
|
if (i === length) {
|
|
198
|
-
throw new Error(
|
|
199
|
+
throw new Error(`Unterminated identifier starting at position ${pos}`)
|
|
199
200
|
}
|
|
200
201
|
const c = nextChar()
|
|
201
202
|
if (c === quote) {
|
|
@@ -285,9 +286,9 @@ export function tokenize(sql) {
|
|
|
285
286
|
}
|
|
286
287
|
|
|
287
288
|
if (tokens.length === 0) {
|
|
288
|
-
throw new Error(
|
|
289
|
+
throw new Error(`Expected SELECT but found "${ch}" at position ${pos}`)
|
|
289
290
|
}
|
|
290
|
-
throw new Error(
|
|
291
|
+
throw new Error(`Unexpected character "${ch}" at position ${pos}`)
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
tokens.push({
|
package/src/types.d.ts
CHANGED
|
@@ -10,15 +10,21 @@ export interface DataSource {
|
|
|
10
10
|
|
|
11
11
|
export interface ExecuteSqlOptions {
|
|
12
12
|
source: Record<string, any>[] | DataSource
|
|
13
|
-
|
|
13
|
+
query: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export type SqlPrimitive = string | number | bigint | boolean | null
|
|
17
17
|
|
|
18
|
+
export interface FromSubquery {
|
|
19
|
+
kind: 'subquery'
|
|
20
|
+
query: SelectStatement
|
|
21
|
+
alias: string
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export interface SelectStatement {
|
|
19
25
|
distinct: boolean
|
|
20
26
|
columns: SelectColumn[]
|
|
21
|
-
from?: string
|
|
27
|
+
from?: string | FromSubquery
|
|
22
28
|
joins: JoinClause[]
|
|
23
29
|
where?: ExprNode
|
|
24
30
|
groupBy: ExprNode[]
|
|
@@ -102,6 +108,17 @@ export interface BetweenNode {
|
|
|
102
108
|
upper: ExprNode
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
export interface InNode {
|
|
112
|
+
type: 'in' | 'not in'
|
|
113
|
+
expr: ExprNode
|
|
114
|
+
subquery: SelectStatement
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ExistsNode {
|
|
118
|
+
type: 'exists' | 'not exists'
|
|
119
|
+
subquery: SelectStatement
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
export type ExprNode =
|
|
106
123
|
| LiteralNode
|
|
107
124
|
| IdentifierNode
|
|
@@ -110,6 +127,8 @@ export type ExprNode =
|
|
|
110
127
|
| FunctionNode
|
|
111
128
|
| CastNode
|
|
112
129
|
| BetweenNode
|
|
130
|
+
| InNode
|
|
131
|
+
| ExistsNode
|
|
113
132
|
|
|
114
133
|
export interface StarColumn {
|
|
115
134
|
kind: 'star'
|
|
@@ -184,4 +203,5 @@ export interface ExprCursor {
|
|
|
184
203
|
match(type: TokenType, value?: string): boolean
|
|
185
204
|
expect(type: TokenType, value: string): Token
|
|
186
205
|
expectIdentifier(): Token
|
|
206
|
+
parseSubquery?: () => SelectStatement
|
|
187
207
|
}
|