squirreling 0.4.2 → 0.4.4
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 +13 -6
- package/package.json +1 -1
- package/src/execute/aggregates.js +10 -0
- package/src/execute/columns.js +107 -0
- package/src/execute/execute.js +21 -3
- package/src/execute/expression.js +1 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +1 -0
- package/src/parse/comparison.js +198 -0
- package/src/parse/expression.js +3 -219
- package/src/parse/parse.js +12 -21
- package/src/parse/tokenize.js +1 -0
- package/src/types.d.ts +16 -12
- package/src/validation.js +9 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ Squirreling is a streaming async SQL engine for JavaScript. It is designed to pr
|
|
|
29
29
|
|
|
30
30
|
Squirreling returns an async generator, allowing you to process rows one at a time without loading everything into memory.
|
|
31
31
|
|
|
32
|
-
```
|
|
32
|
+
```typescript
|
|
33
33
|
import { executeSql } from 'squirreling'
|
|
34
34
|
|
|
35
35
|
// In-memory table
|
|
@@ -40,12 +40,18 @@ const users = [
|
|
|
40
40
|
// ...more rows
|
|
41
41
|
]
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
type AsyncRow = Record<string, AsyncCell>
|
|
44
|
+
type AsyncCell = () => Promise<SqlPrimitive>
|
|
45
|
+
|
|
46
|
+
// Returns an async iterable of rows with async cells
|
|
47
|
+
const asyncRows: AsyncIterable<AsyncRow> = executeSql({
|
|
45
48
|
tables: { users },
|
|
46
49
|
query: 'SELECT count(*) as cnt FROM users WHERE active = TRUE LIMIT 10',
|
|
47
|
-
})
|
|
48
|
-
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Process rows as they arrive (streaming)
|
|
53
|
+
for await (const { cnt } of asyncRows) {
|
|
54
|
+
console.log('Count', await cnt())
|
|
49
55
|
}
|
|
50
56
|
```
|
|
51
57
|
|
|
@@ -54,7 +60,8 @@ There is an exported helper function `collect` to gather all rows into an array
|
|
|
54
60
|
```javascript
|
|
55
61
|
import { collect, executeSql } from 'squirreling'
|
|
56
62
|
|
|
57
|
-
|
|
63
|
+
// Collect all rows and cells into a materialized array
|
|
64
|
+
const allUsers: Record<string, SqlPrimitive>[] = await collect(executeSql({
|
|
58
65
|
tables: { users },
|
|
59
66
|
query: 'SELECT * FROM users',
|
|
60
67
|
}))
|
package/package.json
CHANGED
|
@@ -16,6 +16,16 @@ export async function evaluateAggregate({ col, rows, tables }) {
|
|
|
16
16
|
|
|
17
17
|
if (func === 'COUNT') {
|
|
18
18
|
if (arg.kind === 'star') return rows.length
|
|
19
|
+
if (arg.quantifier === 'distinct') {
|
|
20
|
+
const seen = new Set()
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
const v = await evaluateExpr({ node: arg.expr, row, tables })
|
|
23
|
+
if (v !== null && v !== undefined) {
|
|
24
|
+
seen.add(v)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return seen.size
|
|
28
|
+
}
|
|
19
29
|
let count = 0
|
|
20
30
|
for (const row of rows) {
|
|
21
31
|
const v = await evaluateExpr({ node: arg.expr, row, tables })
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { ExprNode, SelectStatement, SelectColumn } from '../types.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts column names needed from a SELECT statement.
|
|
7
|
+
*
|
|
8
|
+
* @param {SelectStatement} select
|
|
9
|
+
* @returns {string[] | undefined} array of column names, or undefined if all columns needed
|
|
10
|
+
*/
|
|
11
|
+
export function extractColumns(select) {
|
|
12
|
+
// If any column is SELECT *, we need all columns
|
|
13
|
+
if (select.columns.some(col => col.kind === 'star')) {
|
|
14
|
+
return undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @type {Set<string>} */
|
|
18
|
+
const columns = new Set()
|
|
19
|
+
|
|
20
|
+
// Columns from SELECT list
|
|
21
|
+
for (const col of select.columns) {
|
|
22
|
+
collectColumnsFromSelectColumn(col, columns)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Columns from WHERE
|
|
26
|
+
collectColumnsFromExpr(select.where, columns)
|
|
27
|
+
|
|
28
|
+
// Columns from ORDER BY
|
|
29
|
+
for (const item of select.orderBy) {
|
|
30
|
+
collectColumnsFromExpr(item.expr, columns)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Columns from GROUP BY
|
|
34
|
+
for (const expr of select.groupBy) {
|
|
35
|
+
collectColumnsFromExpr(expr, columns)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Columns from HAVING
|
|
39
|
+
collectColumnsFromExpr(select.having, columns)
|
|
40
|
+
|
|
41
|
+
return [...columns]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Collects column names from a SELECT column
|
|
46
|
+
*
|
|
47
|
+
* @param {SelectColumn} col
|
|
48
|
+
* @param {Set<string>} columns
|
|
49
|
+
*/
|
|
50
|
+
function collectColumnsFromSelectColumn(col, columns) {
|
|
51
|
+
if (col.kind === 'derived') {
|
|
52
|
+
collectColumnsFromExpr(col.expr, columns)
|
|
53
|
+
} else if (col.kind === 'aggregate') {
|
|
54
|
+
if (col.arg.kind === 'expression') {
|
|
55
|
+
collectColumnsFromExpr(col.arg.expr, columns)
|
|
56
|
+
}
|
|
57
|
+
// 'star' aggregate (COUNT(*)) doesn't reference specific columns
|
|
58
|
+
}
|
|
59
|
+
// 'star' columns handled separately (returns undefined for all columns)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Recursively collects column names (identifiers) from an expression
|
|
64
|
+
*
|
|
65
|
+
* @param {ExprNode | undefined} expr
|
|
66
|
+
* @param {Set<string>} columns
|
|
67
|
+
*/
|
|
68
|
+
function collectColumnsFromExpr(expr, columns) {
|
|
69
|
+
if (!expr) return
|
|
70
|
+
if (expr.type === 'identifier') {
|
|
71
|
+
columns.add(expr.name)
|
|
72
|
+
} else if (expr.type === 'literal') {
|
|
73
|
+
// No columns
|
|
74
|
+
} else if (expr.type === 'binary') {
|
|
75
|
+
collectColumnsFromExpr(expr.left, columns)
|
|
76
|
+
collectColumnsFromExpr(expr.right, columns)
|
|
77
|
+
} else if (expr.type === 'unary') {
|
|
78
|
+
collectColumnsFromExpr(expr.argument, columns)
|
|
79
|
+
} else if (expr.type === 'function') {
|
|
80
|
+
for (const arg of expr.args) {
|
|
81
|
+
collectColumnsFromExpr(arg, columns)
|
|
82
|
+
}
|
|
83
|
+
} else if (expr.type === 'cast') {
|
|
84
|
+
collectColumnsFromExpr(expr.expr, columns)
|
|
85
|
+
} else if (expr.type === 'in valuelist') {
|
|
86
|
+
collectColumnsFromExpr(expr.expr, columns)
|
|
87
|
+
for (const val of expr.values) {
|
|
88
|
+
collectColumnsFromExpr(val, columns)
|
|
89
|
+
}
|
|
90
|
+
} else if (expr.type === 'in') {
|
|
91
|
+
collectColumnsFromExpr(expr.expr, columns)
|
|
92
|
+
// Subquery columns are from a different scope, don't collect
|
|
93
|
+
} else if (expr.type === 'exists' || expr.type === 'not exists') {
|
|
94
|
+
// Subquery columns are from a different scope, don't collect
|
|
95
|
+
} else if (expr.type === 'case') {
|
|
96
|
+
if (expr.caseExpr) {
|
|
97
|
+
collectColumnsFromExpr(expr.caseExpr, columns)
|
|
98
|
+
}
|
|
99
|
+
for (const when of expr.whenClauses) {
|
|
100
|
+
collectColumnsFromExpr(when.condition, columns)
|
|
101
|
+
collectColumnsFromExpr(when.result, columns)
|
|
102
|
+
}
|
|
103
|
+
if (expr.elseResult) {
|
|
104
|
+
collectColumnsFromExpr(expr.elseResult, columns)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/execute/execute.js
CHANGED
|
@@ -5,9 +5,10 @@ import { evaluateExpr } from './expression.js'
|
|
|
5
5
|
import { evaluateHavingExpr } from './having.js'
|
|
6
6
|
import { executeJoins } from './join.js'
|
|
7
7
|
import { compareForTerm, defaultDerivedAlias } from './utils.js'
|
|
8
|
+
import { extractColumns } from './columns.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* @import { AsyncDataSource, ExecuteSqlOptions, ExprNode, OrderByItem,
|
|
11
|
+
* @import { AsyncDataSource, AsyncRow, ExecuteSqlOptions, ExprNode, OrderByItem, QueryHints, SelectStatement, SqlPrimitive } from '../types.js'
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -239,7 +240,16 @@ async function* evaluateStreaming(select, dataSource, tables) {
|
|
|
239
240
|
/** @type {Set<string> | undefined} */
|
|
240
241
|
const seen = select.distinct ? new Set() : undefined
|
|
241
242
|
|
|
242
|
-
|
|
243
|
+
// hints for data source optimization
|
|
244
|
+
/** @type {QueryHints} */
|
|
245
|
+
const hints = {
|
|
246
|
+
columns: extractColumns(select),
|
|
247
|
+
where: select.where,
|
|
248
|
+
limit: select.limit,
|
|
249
|
+
offset: select.offset,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for await (const row of dataSource.getRows(hints)) {
|
|
243
253
|
// WHERE filter
|
|
244
254
|
if (select.where) {
|
|
245
255
|
const pass = await evaluateExpr({ node: select.where, row, tables })
|
|
@@ -301,10 +311,18 @@ async function* evaluateStreaming(select, dataSource, tables) {
|
|
|
301
311
|
* @yields {AsyncRow}
|
|
302
312
|
*/
|
|
303
313
|
async function* evaluateBuffered(select, dataSource, tables, hasAggregate, useGrouping) {
|
|
314
|
+
// Build hints for data source optimization
|
|
315
|
+
// Note: limit/offset not passed here since buffering needs all rows for sorting/grouping
|
|
316
|
+
/** @type {QueryHints} */
|
|
317
|
+
const hints = {
|
|
318
|
+
where: select.where,
|
|
319
|
+
columns: extractColumns(select),
|
|
320
|
+
}
|
|
321
|
+
|
|
304
322
|
// Step 1: Collect all rows from data source
|
|
305
323
|
/** @type {AsyncRow[]} */
|
|
306
324
|
const working = []
|
|
307
|
-
for await (const row of dataSource.getRows()) {
|
|
325
|
+
for await (const row of dataSource.getRows(hints)) {
|
|
308
326
|
working.push(row)
|
|
309
327
|
}
|
|
310
328
|
|
|
@@ -210,7 +210,7 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
210
210
|
if (isNaN(num)) return null
|
|
211
211
|
return num
|
|
212
212
|
}
|
|
213
|
-
if (toType === 'TEXT' || toType === 'STRING') {
|
|
213
|
+
if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
|
|
214
214
|
return String(val)
|
|
215
215
|
}
|
|
216
216
|
if (toType === 'BOOLEAN' || toType === 'BOOL') {
|
package/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AsyncRow, ExecuteSqlOptions, SelectStatement, SqlPrimitive } from './types.js'
|
|
1
|
+
import type { AsyncDataSource, AsyncRow, ExecuteSqlOptions, SelectStatement, SqlPrimitive } from './types.js'
|
|
2
2
|
export type { AsyncDataSource, AsyncRow, SqlPrimitive } from './types.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -26,3 +26,5 @@ export function parseSql(query: string): SelectStatement
|
|
|
26
26
|
* @returns array of all yielded values
|
|
27
27
|
*/
|
|
28
28
|
export function collect<T>(asyncGen: AsyncGenerator<AsyncRow>): Promise<Record<string, SqlPrimitive>[]>
|
|
29
|
+
|
|
30
|
+
export function cachedDataSource(source: AsyncDataSource): AsyncDataSource
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { isBinaryOp } from '../validation.js'
|
|
2
|
+
import { parseExpression, parsePrimary } from './expression.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @import { ExprCursor, ExprNode } from '../types.js'
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {ExprCursor} c
|
|
10
|
+
* @returns {ExprNode}
|
|
11
|
+
*/
|
|
12
|
+
export function parseComparison(c) {
|
|
13
|
+
const left = parsePrimary(c)
|
|
14
|
+
const tok = c.current()
|
|
15
|
+
|
|
16
|
+
// IS [NOT] NULL
|
|
17
|
+
if (tok.type === 'keyword' && tok.value === 'IS') {
|
|
18
|
+
c.consume()
|
|
19
|
+
const notToken = c.current()
|
|
20
|
+
if (notToken.type === 'keyword' && notToken.value === 'NOT') {
|
|
21
|
+
c.consume()
|
|
22
|
+
c.expect('keyword', 'NULL')
|
|
23
|
+
return {
|
|
24
|
+
type: 'unary',
|
|
25
|
+
op: 'IS NOT NULL',
|
|
26
|
+
argument: left,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
c.expect('keyword', 'NULL')
|
|
30
|
+
return {
|
|
31
|
+
type: 'unary',
|
|
32
|
+
op: 'IS NULL',
|
|
33
|
+
argument: left,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// [NOT] LIKE
|
|
38
|
+
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
39
|
+
const nextTok = c.peek(1)
|
|
40
|
+
if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
|
|
41
|
+
c.consume() // NOT
|
|
42
|
+
c.consume() // LIKE
|
|
43
|
+
const right = parsePrimary(c)
|
|
44
|
+
return {
|
|
45
|
+
type: 'unary',
|
|
46
|
+
op: 'NOT',
|
|
47
|
+
argument: {
|
|
48
|
+
type: 'binary',
|
|
49
|
+
op: 'LIKE',
|
|
50
|
+
left,
|
|
51
|
+
right,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (tok.type === 'keyword' && tok.value === 'LIKE') {
|
|
58
|
+
c.consume()
|
|
59
|
+
const right = parsePrimary(c)
|
|
60
|
+
return {
|
|
61
|
+
type: 'binary',
|
|
62
|
+
op: 'LIKE',
|
|
63
|
+
left,
|
|
64
|
+
right,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// [NOT] BETWEEN - convert to range comparison
|
|
69
|
+
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
70
|
+
const nextTok = c.peek(1)
|
|
71
|
+
if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
|
|
72
|
+
c.consume() // NOT
|
|
73
|
+
c.consume() // BETWEEN
|
|
74
|
+
const lower = parsePrimary(c)
|
|
75
|
+
c.expect('keyword', 'AND')
|
|
76
|
+
const upper = parsePrimary(c)
|
|
77
|
+
// NOT BETWEEN -> expr < lower OR expr > upper
|
|
78
|
+
return {
|
|
79
|
+
type: 'binary',
|
|
80
|
+
op: 'OR',
|
|
81
|
+
left: { type: 'binary', op: '<', left, right: lower },
|
|
82
|
+
right: { type: 'binary', op: '>', left, right: upper },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
|
|
88
|
+
c.consume()
|
|
89
|
+
const lower = parsePrimary(c)
|
|
90
|
+
c.expect('keyword', 'AND')
|
|
91
|
+
const upper = parsePrimary(c)
|
|
92
|
+
// BETWEEN -> expr >= lower AND expr <= upper
|
|
93
|
+
return {
|
|
94
|
+
type: 'binary',
|
|
95
|
+
op: 'AND',
|
|
96
|
+
left: { type: 'binary', op: '>=', left, right: lower },
|
|
97
|
+
right: { type: 'binary', op: '<=', left, right: upper },
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// [NOT] IN
|
|
102
|
+
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
103
|
+
const nextTok = c.peek(1)
|
|
104
|
+
if (nextTok.type === 'keyword' && nextTok.value === 'IN') {
|
|
105
|
+
c.consume() // NOT
|
|
106
|
+
c.consume() // IN
|
|
107
|
+
|
|
108
|
+
// Check if it's a subquery or a list of values by peeking ahead
|
|
109
|
+
// parseSubquery expects to consume the opening paren itself
|
|
110
|
+
const parenTok = c.current()
|
|
111
|
+
if (parenTok.type !== 'paren' || parenTok.value !== '(') {
|
|
112
|
+
throw new Error('Expected ( after IN')
|
|
113
|
+
}
|
|
114
|
+
const peekTok = c.peek(1)
|
|
115
|
+
if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
|
|
116
|
+
// Subquery - let parseSubquery handle the parens
|
|
117
|
+
const subquery = c.parseSubquery()
|
|
118
|
+
return {
|
|
119
|
+
type: 'unary',
|
|
120
|
+
op: 'NOT',
|
|
121
|
+
argument: {
|
|
122
|
+
type: 'in',
|
|
123
|
+
expr: left,
|
|
124
|
+
subquery,
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// Parse list of values - we handle the parens
|
|
129
|
+
c.consume() // '('
|
|
130
|
+
/** @type {ExprNode[]} */
|
|
131
|
+
const values = []
|
|
132
|
+
while (true) {
|
|
133
|
+
values.push(parseExpression(c))
|
|
134
|
+
if (!c.match('comma')) break
|
|
135
|
+
}
|
|
136
|
+
c.expect('paren', ')')
|
|
137
|
+
return {
|
|
138
|
+
type: 'unary',
|
|
139
|
+
op: 'NOT',
|
|
140
|
+
argument: {
|
|
141
|
+
type: 'in valuelist',
|
|
142
|
+
expr: left,
|
|
143
|
+
values,
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (tok.type === 'keyword' && tok.value === 'IN') {
|
|
151
|
+
c.consume() // IN
|
|
152
|
+
|
|
153
|
+
// Check if it's a subquery or a list of values by peeking ahead
|
|
154
|
+
// parseSubquery expects to consume the opening paren itself
|
|
155
|
+
const parenTok = c.current()
|
|
156
|
+
if (parenTok.type !== 'paren' || parenTok.value !== '(') {
|
|
157
|
+
throw new Error('Expected ( after IN')
|
|
158
|
+
}
|
|
159
|
+
const peekTok = c.peek(1)
|
|
160
|
+
if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
|
|
161
|
+
// Subquery - let parseSubquery handle the parens
|
|
162
|
+
const subquery = c.parseSubquery()
|
|
163
|
+
return {
|
|
164
|
+
type: 'in',
|
|
165
|
+
expr: left,
|
|
166
|
+
subquery,
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
// Parse list of values - we handle the parens
|
|
170
|
+
c.consume() // '('
|
|
171
|
+
/** @type {ExprNode[]} */
|
|
172
|
+
const values = []
|
|
173
|
+
while (true) {
|
|
174
|
+
values.push(parseExpression(c))
|
|
175
|
+
if (!c.match('comma')) break
|
|
176
|
+
}
|
|
177
|
+
c.expect('paren', ')')
|
|
178
|
+
return {
|
|
179
|
+
type: 'in valuelist',
|
|
180
|
+
expr: left,
|
|
181
|
+
values,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (tok.type === 'operator' && isBinaryOp(tok.value)) {
|
|
187
|
+
c.consume()
|
|
188
|
+
const right = parsePrimary(c)
|
|
189
|
+
return {
|
|
190
|
+
type: 'binary',
|
|
191
|
+
op: tok.value,
|
|
192
|
+
left,
|
|
193
|
+
right,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return left
|
|
198
|
+
}
|
package/src/parse/expression.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isAggregateFunc, isStringFunc } from '../validation.js'
|
|
2
|
+
import { parseComparison } from './comparison.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* @import {
|
|
5
|
+
* @import { ExprCursor, ExprNode, WhenClause } from '../types.js'
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -16,7 +17,7 @@ export function parseExpression(c) {
|
|
|
16
17
|
* @param {ExprCursor} c
|
|
17
18
|
* @returns {ExprNode}
|
|
18
19
|
*/
|
|
19
|
-
function parsePrimary(c) {
|
|
20
|
+
export function parsePrimary(c) {
|
|
20
21
|
const tok = c.current()
|
|
21
22
|
|
|
22
23
|
if (tok.type === 'paren' && tok.value === '(') {
|
|
@@ -253,9 +254,6 @@ function parseNot(c) {
|
|
|
253
254
|
const nextTok = c.current()
|
|
254
255
|
if (nextTok.type === 'keyword' && nextTok.value === 'EXISTS') {
|
|
255
256
|
c.consume() // EXISTS
|
|
256
|
-
if (!c.parseSubquery) {
|
|
257
|
-
throw new Error('Subquery parsing not available in this context')
|
|
258
|
-
}
|
|
259
257
|
const subquery = c.parseSubquery()
|
|
260
258
|
return {
|
|
261
259
|
type: 'not exists',
|
|
@@ -271,217 +269,3 @@ function parseNot(c) {
|
|
|
271
269
|
}
|
|
272
270
|
return parseComparison(c)
|
|
273
271
|
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* @param {ExprCursor} c
|
|
277
|
-
* @returns {ExprNode}
|
|
278
|
-
*/
|
|
279
|
-
function parseComparison(c) {
|
|
280
|
-
const left = parsePrimary(c)
|
|
281
|
-
const tok = c.current()
|
|
282
|
-
|
|
283
|
-
// IS [NOT] NULL
|
|
284
|
-
if (tok.type === 'keyword' && tok.value === 'IS') {
|
|
285
|
-
c.consume()
|
|
286
|
-
const notToken = c.current()
|
|
287
|
-
if (notToken.type === 'keyword' && notToken.value === 'NOT') {
|
|
288
|
-
c.consume()
|
|
289
|
-
c.expect('keyword', 'NULL')
|
|
290
|
-
return {
|
|
291
|
-
type: 'unary',
|
|
292
|
-
op: 'IS NOT NULL',
|
|
293
|
-
argument: left,
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
c.expect('keyword', 'NULL')
|
|
297
|
-
return {
|
|
298
|
-
type: 'unary',
|
|
299
|
-
op: 'IS NULL',
|
|
300
|
-
argument: left,
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// [NOT] LIKE
|
|
305
|
-
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
306
|
-
const nextTok = c.peek(1)
|
|
307
|
-
if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
|
|
308
|
-
c.consume() // NOT
|
|
309
|
-
c.consume() // LIKE
|
|
310
|
-
const right = parsePrimary(c)
|
|
311
|
-
return {
|
|
312
|
-
type: 'unary',
|
|
313
|
-
op: 'NOT',
|
|
314
|
-
argument: {
|
|
315
|
-
type: 'binary',
|
|
316
|
-
op: 'LIKE',
|
|
317
|
-
left,
|
|
318
|
-
right,
|
|
319
|
-
},
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (tok.type === 'keyword' && tok.value === 'LIKE') {
|
|
325
|
-
c.consume()
|
|
326
|
-
const right = parsePrimary(c)
|
|
327
|
-
return {
|
|
328
|
-
type: 'binary',
|
|
329
|
-
op: 'LIKE',
|
|
330
|
-
left,
|
|
331
|
-
right,
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// [NOT] BETWEEN - convert to range comparison
|
|
336
|
-
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
337
|
-
const nextTok = c.peek(1)
|
|
338
|
-
if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
|
|
339
|
-
c.consume() // NOT
|
|
340
|
-
c.consume() // BETWEEN
|
|
341
|
-
const lower = parsePrimary(c)
|
|
342
|
-
c.expect('keyword', 'AND')
|
|
343
|
-
const upper = parsePrimary(c)
|
|
344
|
-
// NOT BETWEEN -> expr < lower OR expr > upper
|
|
345
|
-
return {
|
|
346
|
-
type: 'binary',
|
|
347
|
-
op: 'OR',
|
|
348
|
-
left: { type: 'binary', op: '<', left, right: lower },
|
|
349
|
-
right: { type: 'binary', op: '>', left, right: upper },
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
|
|
355
|
-
c.consume()
|
|
356
|
-
const lower = parsePrimary(c)
|
|
357
|
-
c.expect('keyword', 'AND')
|
|
358
|
-
const upper = parsePrimary(c)
|
|
359
|
-
// BETWEEN -> expr >= lower AND expr <= upper
|
|
360
|
-
return {
|
|
361
|
-
type: 'binary',
|
|
362
|
-
op: 'AND',
|
|
363
|
-
left: { type: 'binary', op: '>=', left, right: lower },
|
|
364
|
-
right: { type: 'binary', op: '<=', left, right: upper },
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// [NOT] IN
|
|
369
|
-
if (tok.type === 'keyword' && tok.value === 'NOT') {
|
|
370
|
-
const nextTok = c.peek(1)
|
|
371
|
-
if (nextTok.type === 'keyword' && nextTok.value === 'IN') {
|
|
372
|
-
c.consume() // NOT
|
|
373
|
-
c.consume() // IN
|
|
374
|
-
|
|
375
|
-
// Check if it's a subquery or a list of values by peeking ahead
|
|
376
|
-
// parseSubquery expects to consume the opening paren itself
|
|
377
|
-
const parenTok = c.current()
|
|
378
|
-
if (parenTok.type !== 'paren' || parenTok.value !== '(') {
|
|
379
|
-
throw new Error('Expected ( after IN')
|
|
380
|
-
}
|
|
381
|
-
const peekTok = c.peek(1)
|
|
382
|
-
if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
|
|
383
|
-
// Subquery - let parseSubquery handle the parens
|
|
384
|
-
if (!c.parseSubquery) {
|
|
385
|
-
throw new Error('Subquery parsing not available in this context')
|
|
386
|
-
}
|
|
387
|
-
const subquery = c.parseSubquery()
|
|
388
|
-
return {
|
|
389
|
-
type: 'unary',
|
|
390
|
-
op: 'NOT',
|
|
391
|
-
argument: {
|
|
392
|
-
type: 'in',
|
|
393
|
-
expr: left,
|
|
394
|
-
subquery,
|
|
395
|
-
},
|
|
396
|
-
}
|
|
397
|
-
} else {
|
|
398
|
-
// Parse list of values - we handle the parens
|
|
399
|
-
c.consume() // '('
|
|
400
|
-
/** @type {ExprNode[]} */
|
|
401
|
-
const values = []
|
|
402
|
-
while (true) {
|
|
403
|
-
values.push(parseExpression(c))
|
|
404
|
-
if (!c.match('comma')) break
|
|
405
|
-
}
|
|
406
|
-
c.expect('paren', ')')
|
|
407
|
-
return {
|
|
408
|
-
type: 'unary',
|
|
409
|
-
op: 'NOT',
|
|
410
|
-
argument: {
|
|
411
|
-
type: 'in valuelist',
|
|
412
|
-
expr: left,
|
|
413
|
-
values,
|
|
414
|
-
},
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (tok.type === 'keyword' && tok.value === 'IN') {
|
|
421
|
-
c.consume() // IN
|
|
422
|
-
|
|
423
|
-
// Check if it's a subquery or a list of values by peeking ahead
|
|
424
|
-
// parseSubquery expects to consume the opening paren itself
|
|
425
|
-
const parenTok = c.current()
|
|
426
|
-
if (parenTok.type !== 'paren' || parenTok.value !== '(') {
|
|
427
|
-
throw new Error('Expected ( after IN')
|
|
428
|
-
}
|
|
429
|
-
const peekTok = c.peek(1)
|
|
430
|
-
if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
|
|
431
|
-
// Subquery - let parseSubquery handle the parens
|
|
432
|
-
if (!c.parseSubquery) {
|
|
433
|
-
throw new Error('Subquery parsing not available in this context')
|
|
434
|
-
}
|
|
435
|
-
const subquery = c.parseSubquery()
|
|
436
|
-
return {
|
|
437
|
-
type: 'in',
|
|
438
|
-
expr: left,
|
|
439
|
-
subquery,
|
|
440
|
-
}
|
|
441
|
-
} else {
|
|
442
|
-
// Parse list of values - we handle the parens
|
|
443
|
-
c.consume() // '('
|
|
444
|
-
/** @type {ExprNode[]} */
|
|
445
|
-
const values = []
|
|
446
|
-
while (true) {
|
|
447
|
-
values.push(parseExpression(c))
|
|
448
|
-
if (!c.match('comma')) break
|
|
449
|
-
}
|
|
450
|
-
c.expect('paren', ')')
|
|
451
|
-
return {
|
|
452
|
-
type: 'in valuelist',
|
|
453
|
-
expr: left,
|
|
454
|
-
values,
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (tok.type === 'operator' && isComparisonOperator(tok.value)) {
|
|
460
|
-
c.consume()
|
|
461
|
-
const right = parsePrimary(c)
|
|
462
|
-
return {
|
|
463
|
-
type: 'binary',
|
|
464
|
-
op: tok.value,
|
|
465
|
-
left,
|
|
466
|
-
right,
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return left
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* @param {string} op
|
|
475
|
-
* @returns {op is BinaryOp}
|
|
476
|
-
*/
|
|
477
|
-
function isComparisonOperator(op) {
|
|
478
|
-
return (
|
|
479
|
-
op === '=' ||
|
|
480
|
-
op === '!=' ||
|
|
481
|
-
op === '<>' ||
|
|
482
|
-
op === '<' ||
|
|
483
|
-
op === '>' ||
|
|
484
|
-
op === '<=' ||
|
|
485
|
-
op === '>='
|
|
486
|
-
)
|
|
487
|
-
}
|
package/src/parse/parse.js
CHANGED
|
@@ -229,31 +229,22 @@ function parseAggregateItem(state, func) {
|
|
|
229
229
|
if (cur.type === 'operator' && cur.value === '*') {
|
|
230
230
|
consume(state)
|
|
231
231
|
arg = { kind: 'star' }
|
|
232
|
-
} else if (cur.type === 'identifier' && cur.value === 'CAST') {
|
|
233
|
-
// Handle CAST inside aggregate: SUM(CAST(x AS type))
|
|
234
|
-
expectIdentifier(state) // consume CAST
|
|
235
|
-
expect(state, 'paren', '(')
|
|
236
|
-
const cursor = createExprCursor(state)
|
|
237
|
-
const expr = parseExpression(cursor)
|
|
238
|
-
expect(state, 'keyword', 'AS')
|
|
239
|
-
const toType = expectIdentifier(state).value
|
|
240
|
-
expect(state, 'paren', ')')
|
|
241
|
-
arg = {
|
|
242
|
-
kind: 'expression',
|
|
243
|
-
expr: { type: 'cast', expr, toType },
|
|
244
|
-
}
|
|
245
232
|
} else {
|
|
246
|
-
|
|
247
|
-
let
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
233
|
+
/** @type {'all' | 'distinct'} */
|
|
234
|
+
let quantifier = 'all'
|
|
235
|
+
if (cur.type === 'keyword' && cur.value === 'ALL') {
|
|
236
|
+
consume(state) // consume ALL
|
|
237
|
+
} else if (cur.type === 'keyword' && cur.value === 'DISTINCT') {
|
|
238
|
+
consume(state)
|
|
239
|
+
quantifier = 'distinct'
|
|
253
240
|
}
|
|
241
|
+
|
|
242
|
+
const cursor = createExprCursor(state)
|
|
243
|
+
const expr = parseExpression(cursor)
|
|
254
244
|
arg = {
|
|
255
245
|
kind: 'expression',
|
|
256
|
-
expr
|
|
246
|
+
expr,
|
|
247
|
+
quantifier,
|
|
257
248
|
}
|
|
258
249
|
}
|
|
259
250
|
|
package/src/parse/tokenize.js
CHANGED
package/src/types.d.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
|
|
2
|
+
/**
|
|
3
|
+
* Hints passed to data sources for query optimization.
|
|
4
|
+
* All hints are optional and "best effort" - sources may ignore them.
|
|
5
|
+
*/
|
|
6
|
+
export interface QueryHints {
|
|
7
|
+
columns?: string[] // columns needed
|
|
8
|
+
where?: ExprNode // where clause
|
|
9
|
+
limit?: number
|
|
10
|
+
offset?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
2
13
|
/**
|
|
3
14
|
* Async data source for streaming SQL execution.
|
|
4
15
|
* Provides an async iterator over rows.
|
|
5
16
|
*/
|
|
6
17
|
export interface AsyncDataSource {
|
|
7
|
-
getRows(): AsyncIterable<AsyncRow>
|
|
18
|
+
getRows(hints?: QueryHints): AsyncIterable<AsyncRow>
|
|
8
19
|
}
|
|
9
20
|
export type AsyncRow = Record<string, AsyncCell>
|
|
10
21
|
export type AsyncCell = () => Promise<SqlPrimitive>
|
|
@@ -43,17 +54,9 @@ export interface FromSubquery {
|
|
|
43
54
|
alias: string
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
export type BinaryOp =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
| '='
|
|
50
|
-
| '!='
|
|
51
|
-
| '<>'
|
|
52
|
-
| '<'
|
|
53
|
-
| '>'
|
|
54
|
-
| '<='
|
|
55
|
-
| '>='
|
|
56
|
-
| 'LIKE'
|
|
57
|
+
export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp
|
|
58
|
+
|
|
59
|
+
export type ComparisonOp = '=' | '!=' | '<>' | '<' | '>' | '<=' | '>='
|
|
57
60
|
|
|
58
61
|
export interface LiteralNode {
|
|
59
62
|
type: 'literal'
|
|
@@ -154,6 +157,7 @@ export interface AggregateArgStar {
|
|
|
154
157
|
export interface AggregateArgExpression {
|
|
155
158
|
kind: 'expression'
|
|
156
159
|
expr: ExprNode
|
|
160
|
+
quantifier: 'all' | 'distinct'
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
export type AggregateArg = AggregateArgStar | AggregateArgExpression
|
package/src/validation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
/**
|
|
3
|
-
* @import {AggregateFunc, StringFunc} from './types.js'
|
|
3
|
+
* @import {AggregateFunc, BinaryOp, StringFunc} from './types.js'
|
|
4
4
|
* @param {string} name
|
|
5
5
|
* @returns {name is AggregateFunc}
|
|
6
6
|
*/
|
|
@@ -15,3 +15,11 @@ export function isAggregateFunc(name) {
|
|
|
15
15
|
export function isStringFunc(name) {
|
|
16
16
|
return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE', 'RANDOM', 'RAND'].includes(name)
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} op
|
|
21
|
+
* @returns {op is BinaryOp}
|
|
22
|
+
*/
|
|
23
|
+
export function isBinaryOp(op) {
|
|
24
|
+
return ['=', '!=', '<>', '<', '>', '<=', '>='].includes(op)
|
|
25
|
+
}
|