squirreling 0.7.0 → 0.7.2
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 -8
- package/package.json +2 -2
- package/src/execute/expression.js +28 -87
- package/src/execute/join.js +98 -29
- package/src/execute/math.js +7 -3
- package/src/execute/regexp.js +159 -0
- package/src/execute/strings.js +145 -0
- package/src/index.d.ts +14 -3
- package/src/index.js +1 -0
- package/src/parse/expression.js +11 -60
- package/src/parse/functions.js +76 -0
- package/src/parse/joins.js +10 -3
- package/src/parse/parse.js +12 -6
- package/src/parse/tokenize.js +2 -1
- package/src/types.d.ts +19 -9
- package/src/validation.js +54 -33
- package/src/validationErrors.js +3 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|

|
|
11
11
|
[](https://www.npmjs.com/package/squirreling?activeTab=dependencies)
|
|
12
12
|
|
|
13
|
-
Squirreling is a streaming async SQL engine for
|
|
13
|
+
Squirreling is a streaming async SQL engine built for the web. It is designed to query over various data sources and provide efficient streaming of results. 100% JavaScript with zero dependencies.
|
|
14
14
|
|
|
15
15
|
## Features
|
|
16
16
|
|
|
@@ -79,11 +79,16 @@ console.log(`Collected rows:`, rows)
|
|
|
79
79
|
|
|
80
80
|
- `SELECT` statements with `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
|
|
81
81
|
- Subqueries in `SELECT`, `FROM`, and `WHERE` clauses
|
|
82
|
-
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`
|
|
82
|
+
- `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `POSITIONAL JOIN`
|
|
83
83
|
- `GROUP BY` and `HAVING` clauses
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
84
|
+
|
|
85
|
+
### Functions
|
|
86
|
+
|
|
87
|
+
- Aggregate: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
|
|
88
|
+
- String: `CONCAT`, `SUBSTRING`, `REPLACE`, `LENGTH`, `UPPER`, `LOWER`, `TRIM`, `LEFT`, `RIGHT`, `INSTR`
|
|
89
|
+
- Math: `ABS`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
|
|
90
|
+
- Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
|
|
91
|
+
- Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
|
|
92
|
+
- Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
|
|
93
|
+
- Regex: `REGEXP_SUBSTR`, `REGEXP_REPLACE`
|
|
94
|
+
- User-defined functions (UDFs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Squirreling SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"test": "vitest run"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@types/node": "
|
|
40
|
+
"@types/node": "25.0.3",
|
|
41
41
|
"@vitest/coverage-v8": "4.0.16",
|
|
42
42
|
"eslint": "9.39.2",
|
|
43
43
|
"eslint-plugin-jsdoc": "61.5.0",
|
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import { unknownFunctionError } from '../parseErrors.js'
|
|
2
2
|
import { invalidContextError } from '../executionErrors.js'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
argValueError,
|
|
6
|
-
castError,
|
|
7
|
-
} from '../validationErrors.js'
|
|
8
|
-
import { isAggregateFunc, isMathFunc } from '../validation.js'
|
|
3
|
+
import { aggregateError, argValueError, castError } from '../validationErrors.js'
|
|
4
|
+
import { isAggregateFunc, isMathFunc, isRegexpFunc, isStringFunc } from '../validation.js'
|
|
9
5
|
import { applyIntervalToDate } from './date.js'
|
|
10
6
|
import { executeSelect } from './execute.js'
|
|
11
7
|
import { evaluateMathFunc } from './math.js'
|
|
8
|
+
import { evaluateRegexpFunc } from './regexp.js'
|
|
9
|
+
import { evaluateStringFunc } from './strings.js'
|
|
12
10
|
import { applyBinaryOp, stringify } from './utils.js'
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
|
-
* @import { ExprNode, AsyncRow, SqlPrimitive, AsyncDataSource,
|
|
13
|
+
* @import { ExprNode, AsyncRow, SqlPrimitive, AsyncDataSource, UserDefinedFunction } from '../types.js'
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
16
|
/**
|
|
@@ -200,90 +198,33 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
|
|
|
200
198
|
/** @type {SqlPrimitive[]} */
|
|
201
199
|
const args = await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, tables, functions, rowIndex, rows })))
|
|
202
200
|
|
|
203
|
-
if (funcName
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (val == null) return null
|
|
212
|
-
return String(val).toLowerCase()
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (funcName === 'CONCAT') {
|
|
216
|
-
// SQL CONCAT returns NULL if any argument is NULL
|
|
217
|
-
if (args.some(a => a == null)) return null
|
|
218
|
-
if (args.some(a => typeof a === 'object')) {
|
|
219
|
-
throw argValueError({
|
|
220
|
-
funcName: 'CONCAT',
|
|
221
|
-
message: 'does not support object arguments',
|
|
222
|
-
positionStart: node.positionStart,
|
|
223
|
-
positionEnd: node.positionEnd,
|
|
224
|
-
hint: 'Use CAST to convert objects to strings first.',
|
|
225
|
-
rowNumber: rowIndex,
|
|
226
|
-
})
|
|
227
|
-
}
|
|
228
|
-
return args.map(a => String(a)).join('')
|
|
201
|
+
if (isStringFunc(funcName)) {
|
|
202
|
+
return evaluateStringFunc({
|
|
203
|
+
funcName,
|
|
204
|
+
args,
|
|
205
|
+
positionStart: node.positionStart,
|
|
206
|
+
positionEnd: node.positionEnd,
|
|
207
|
+
rowIndex,
|
|
208
|
+
})
|
|
229
209
|
}
|
|
230
210
|
|
|
231
|
-
if (funcName
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
211
|
+
if (isRegexpFunc(funcName)) {
|
|
212
|
+
return evaluateRegexpFunc({
|
|
213
|
+
funcName,
|
|
214
|
+
args,
|
|
215
|
+
positionStart: node.positionStart,
|
|
216
|
+
positionEnd: node.positionEnd,
|
|
217
|
+
rowIndex,
|
|
218
|
+
})
|
|
235
219
|
}
|
|
236
220
|
|
|
237
|
-
if (funcName === '
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (!Number.isInteger(start) || start < 1) {
|
|
243
|
-
throw argValueError({
|
|
244
|
-
funcName,
|
|
245
|
-
message: `start position must be a positive integer, got ${args[1]}`,
|
|
246
|
-
positionStart: node.positionStart,
|
|
247
|
-
positionEnd: node.positionEnd,
|
|
248
|
-
hint: 'SQL uses 1-based indexing.',
|
|
249
|
-
rowNumber: rowIndex,
|
|
250
|
-
})
|
|
251
|
-
}
|
|
252
|
-
// SQL uses 1-based indexing
|
|
253
|
-
const startIdx = start - 1
|
|
254
|
-
if (args.length === 3) {
|
|
255
|
-
const len = Number(args[2])
|
|
256
|
-
if (!Number.isInteger(len) || len < 0) {
|
|
257
|
-
throw argValueError({
|
|
258
|
-
funcName,
|
|
259
|
-
message: `length must be a non-negative integer, got ${args[2]}`,
|
|
260
|
-
positionStart: node.positionStart,
|
|
261
|
-
positionEnd: node.positionEnd,
|
|
262
|
-
rowNumber: rowIndex,
|
|
263
|
-
})
|
|
264
|
-
}
|
|
265
|
-
return strVal.substring(startIdx, startIdx + len)
|
|
221
|
+
if (funcName === 'COALESCE') {
|
|
222
|
+
// Short-circuit: evaluate args one at a time, return first non-null
|
|
223
|
+
for (const arg of node.args) {
|
|
224
|
+
const val = await evaluateExpr({ node: arg, row, tables, functions, rowIndex, rows })
|
|
225
|
+
if (val != null) return val
|
|
266
226
|
}
|
|
267
|
-
return
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (funcName === 'TRIM') {
|
|
271
|
-
const val = args[0]
|
|
272
|
-
if (val == null) return null
|
|
273
|
-
return String(val).trim()
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (funcName === 'REPLACE') {
|
|
277
|
-
const str = args[0]
|
|
278
|
-
const searchStr = args[1]
|
|
279
|
-
const replaceStr = args[2]
|
|
280
|
-
// SQL REPLACE returns NULL if any argument is NULL
|
|
281
|
-
if (str == null || searchStr == null || replaceStr == null) return null
|
|
282
|
-
return String(str).replaceAll(String(searchStr), String(replaceStr))
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
if (funcName === 'RANDOM' || funcName === 'RAND') {
|
|
286
|
-
return Math.random()
|
|
227
|
+
return null
|
|
287
228
|
}
|
|
288
229
|
|
|
289
230
|
if (funcName === 'CURRENT_DATE') {
|
|
@@ -392,7 +333,7 @@ export async function evaluateExpr({ node, row, tables, functions, rowIndex, row
|
|
|
392
333
|
if (functions) {
|
|
393
334
|
const udfName = Object.keys(functions).find(k => k.toUpperCase() === funcName)
|
|
394
335
|
if (udfName) {
|
|
395
|
-
return await functions[udfName](...args)
|
|
336
|
+
return await functions[udfName].apply(...args)
|
|
396
337
|
}
|
|
397
338
|
}
|
|
398
339
|
|
package/src/execute/join.js
CHANGED
|
@@ -43,16 +43,26 @@ export async function executeJoins({ leftSource, joins, leftTable, tables, funct
|
|
|
43
43
|
return {
|
|
44
44
|
async *scan(options) {
|
|
45
45
|
const { signal } = options
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
if (join.joinType === 'POSITIONAL') {
|
|
47
|
+
yield* positionalJoin({
|
|
48
|
+
leftRows: leftSource.scan(options),
|
|
49
|
+
rightRows,
|
|
50
|
+
leftTable: currentLeftTable,
|
|
51
|
+
rightTable,
|
|
52
|
+
signal,
|
|
53
|
+
})
|
|
54
|
+
} else {
|
|
55
|
+
yield* hashJoin({
|
|
56
|
+
leftRows: leftSource.scan(options), // Stream directly, not buffered
|
|
57
|
+
rightRows,
|
|
58
|
+
join,
|
|
59
|
+
leftTable: currentLeftTable,
|
|
60
|
+
rightTable,
|
|
61
|
+
tables,
|
|
62
|
+
functions,
|
|
63
|
+
signal,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
56
66
|
},
|
|
57
67
|
}
|
|
58
68
|
}
|
|
@@ -84,15 +94,22 @@ export async function executeJoins({ leftSource, joins, leftTable, tables, funct
|
|
|
84
94
|
// Collect intermediate results into array for next join
|
|
85
95
|
/** @type {AsyncRow[]} */
|
|
86
96
|
const newLeftRows = []
|
|
87
|
-
const joined =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
const joined = join.joinType === 'POSITIONAL'
|
|
98
|
+
? positionalJoin({
|
|
99
|
+
leftRows,
|
|
100
|
+
rightRows,
|
|
101
|
+
leftTable: currentLeftTable,
|
|
102
|
+
rightTable,
|
|
103
|
+
})
|
|
104
|
+
: hashJoin({
|
|
105
|
+
leftRows,
|
|
106
|
+
rightRows,
|
|
107
|
+
join,
|
|
108
|
+
leftTable: currentLeftTable,
|
|
109
|
+
rightTable,
|
|
110
|
+
tables,
|
|
111
|
+
functions,
|
|
112
|
+
})
|
|
96
113
|
for await (const row of joined) {
|
|
97
114
|
newLeftRows.push(row)
|
|
98
115
|
}
|
|
@@ -121,16 +138,26 @@ export async function executeJoins({ leftSource, joins, leftTable, tables, funct
|
|
|
121
138
|
return {
|
|
122
139
|
async *scan(options) {
|
|
123
140
|
const { signal } = options
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
141
|
+
if (join.joinType === 'POSITIONAL') {
|
|
142
|
+
yield* positionalJoin({
|
|
143
|
+
leftRows,
|
|
144
|
+
rightRows,
|
|
145
|
+
leftTable: currentLeftTable,
|
|
146
|
+
rightTable,
|
|
147
|
+
signal,
|
|
148
|
+
})
|
|
149
|
+
} else {
|
|
150
|
+
yield* hashJoin({
|
|
151
|
+
leftRows,
|
|
152
|
+
rightRows,
|
|
153
|
+
join,
|
|
154
|
+
leftTable: currentLeftTable,
|
|
155
|
+
rightTable,
|
|
156
|
+
tables,
|
|
157
|
+
functions,
|
|
158
|
+
signal,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
134
161
|
},
|
|
135
162
|
}
|
|
136
163
|
}
|
|
@@ -230,6 +257,48 @@ function mergeRows(leftRow, rightRow, leftTable, rightTable) {
|
|
|
230
257
|
return { columns, cells }
|
|
231
258
|
}
|
|
232
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Performs a positional join between left and right row sets.
|
|
262
|
+
* Matches rows by their index position (row 0 with row 0, row 1 with row 1, etc.).
|
|
263
|
+
* When tables have different lengths, the shorter table is padded with NULLs.
|
|
264
|
+
*
|
|
265
|
+
* @param {Object} params
|
|
266
|
+
* @param {AsyncIterable<AsyncRow>|AsyncRow[]} params.leftRows - rows from left table
|
|
267
|
+
* @param {AsyncRow[]} params.rightRows - rows from right table (must be buffered)
|
|
268
|
+
* @param {string} params.leftTable - name of left table (for column prefixing)
|
|
269
|
+
* @param {string} params.rightTable - name of right table (for column prefixing, may be alias)
|
|
270
|
+
* @param {AbortSignal} [params.signal] - abort signal for cancellation
|
|
271
|
+
* @yields {AsyncRow} joined rows
|
|
272
|
+
*/
|
|
273
|
+
async function* positionalJoin({ leftRows, rightRows, leftTable, rightTable, signal }) {
|
|
274
|
+
// Buffer left rows if streaming
|
|
275
|
+
/** @type {AsyncRow[]} */
|
|
276
|
+
const leftArr = []
|
|
277
|
+
for await (const row of leftRows) {
|
|
278
|
+
if (signal?.aborted) return
|
|
279
|
+
leftArr.push(row)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const maxLen = Math.max(leftArr.length, rightRows.length)
|
|
283
|
+
|
|
284
|
+
// Get column info for NULL row creation
|
|
285
|
+
const leftCols = leftArr[0]?.columns ?? []
|
|
286
|
+
const rightCols = rightRows[0]?.columns ?? []
|
|
287
|
+
const leftPrefixedCols = leftCols.flatMap(col =>
|
|
288
|
+
col.includes('.') ? [col] : [`${leftTable}.${col}`, col]
|
|
289
|
+
)
|
|
290
|
+
const rightPrefixedCols = rightCols.flatMap(col =>
|
|
291
|
+
col.includes('.') ? [col] : [`${rightTable}.${col}`, col]
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
for (let i = 0; i < maxLen; i++) {
|
|
295
|
+
if (signal?.aborted) return
|
|
296
|
+
const leftRow = leftArr[i] ?? createNullRow(leftPrefixedCols)
|
|
297
|
+
const rightRow = rightRows[i] ?? createNullRow(rightPrefixedCols)
|
|
298
|
+
yield mergeRows(leftRow, rightRow, leftTable, rightTable)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
233
302
|
/**
|
|
234
303
|
* Performs a hash join between left and right row sets (streaming).
|
|
235
304
|
* Yields rows as they are found instead of buffering all results.
|
package/src/execute/math.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* Evaluate a math function
|
|
7
7
|
*
|
|
8
8
|
* @param {Object} options
|
|
9
|
-
* @param {MathFunc} options.funcName
|
|
10
|
-
* @param {SqlPrimitive[]} options.args
|
|
11
|
-
* @returns {SqlPrimitive}
|
|
9
|
+
* @param {MathFunc} options.funcName
|
|
10
|
+
* @param {SqlPrimitive[]} options.args
|
|
11
|
+
* @returns {SqlPrimitive}
|
|
12
12
|
*/
|
|
13
13
|
export function evaluateMathFunc({ funcName, args }) {
|
|
14
14
|
if (funcName === 'FLOOR') {
|
|
@@ -138,4 +138,8 @@ export function evaluateMathFunc({ funcName, args }) {
|
|
|
138
138
|
if (funcName === 'PI') {
|
|
139
139
|
return Math.PI
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
if (funcName === 'RAND' || funcName === 'RANDOM') {
|
|
143
|
+
return Math.random()
|
|
144
|
+
}
|
|
141
145
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { argValueError } from '../validationErrors.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @import { SqlPrimitive } from '../types.js'
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Evaluate a regexp function
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string} options.funcName - Uppercase function name
|
|
12
|
+
* @param {SqlPrimitive[]} options.args - Function arguments
|
|
13
|
+
* @param {number} [options.positionStart] - Start position in SQL string for error reporting
|
|
14
|
+
* @param {number} [options.positionEnd] - End position in SQL string for error reporting
|
|
15
|
+
* @param {number} [options.rowIndex] - Row number for error reporting
|
|
16
|
+
* @returns {SqlPrimitive} Result
|
|
17
|
+
*/
|
|
18
|
+
export function evaluateRegexpFunc({ funcName, args, positionStart, positionEnd, rowIndex }) {
|
|
19
|
+
if (funcName === 'REGEXP_SUBSTR') {
|
|
20
|
+
const str = args[0]
|
|
21
|
+
const pattern = args[1]
|
|
22
|
+
if (str == null || pattern == null) return null
|
|
23
|
+
const strVal = String(str)
|
|
24
|
+
const patternStr = String(pattern)
|
|
25
|
+
|
|
26
|
+
// Default position is 1 (1-based)
|
|
27
|
+
let position = 1
|
|
28
|
+
if (args.length >= 3 && args[2] != null) {
|
|
29
|
+
position = Number(args[2])
|
|
30
|
+
if (!Number.isInteger(position) || position < 1) {
|
|
31
|
+
throw argValueError({
|
|
32
|
+
funcName,
|
|
33
|
+
message: `position must be a positive integer, got ${args[2]}`,
|
|
34
|
+
positionStart,
|
|
35
|
+
positionEnd,
|
|
36
|
+
hint: 'SQL uses 1-based indexing.',
|
|
37
|
+
rowNumber: rowIndex,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Default occurrence is 1
|
|
43
|
+
let occurrence = 1
|
|
44
|
+
if (args.length >= 4 && args[3] != null) {
|
|
45
|
+
occurrence = Number(args[3])
|
|
46
|
+
if (!Number.isInteger(occurrence) || occurrence < 1) {
|
|
47
|
+
throw argValueError({
|
|
48
|
+
funcName,
|
|
49
|
+
message: `occurrence must be a positive integer, got ${args[3]}`,
|
|
50
|
+
positionStart,
|
|
51
|
+
positionEnd,
|
|
52
|
+
rowNumber: rowIndex,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create regex
|
|
58
|
+
let regex
|
|
59
|
+
try {
|
|
60
|
+
regex = new RegExp(patternStr, 'g')
|
|
61
|
+
} catch (/** @type {any} */ error) {
|
|
62
|
+
throw argValueError({
|
|
63
|
+
funcName,
|
|
64
|
+
message: `invalid regex pattern: ${error.message}`,
|
|
65
|
+
positionStart,
|
|
66
|
+
positionEnd,
|
|
67
|
+
rowNumber: rowIndex,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Search from position (convert to 0-based)
|
|
72
|
+
const searchStr = strVal.substring(position - 1)
|
|
73
|
+
|
|
74
|
+
// Find the nth occurrence
|
|
75
|
+
let match
|
|
76
|
+
let count = 0
|
|
77
|
+
while ((match = regex.exec(searchStr)) !== null) {
|
|
78
|
+
count++
|
|
79
|
+
if (count === occurrence) {
|
|
80
|
+
return match[0]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (funcName === 'REGEXP_REPLACE') {
|
|
88
|
+
const str = args[0]
|
|
89
|
+
const pattern = args[1]
|
|
90
|
+
const replacement = args[2]
|
|
91
|
+
if (str == null || pattern == null || replacement == null) return null
|
|
92
|
+
const strVal = String(str)
|
|
93
|
+
const patternStr = String(pattern)
|
|
94
|
+
const replacementStr = String(replacement)
|
|
95
|
+
|
|
96
|
+
// Default position is 1 (1-based)
|
|
97
|
+
let position = 1
|
|
98
|
+
if (args.length >= 4 && args[3] != null) {
|
|
99
|
+
position = Number(args[3])
|
|
100
|
+
if (!Number.isInteger(position) || position < 1) {
|
|
101
|
+
throw argValueError({
|
|
102
|
+
funcName,
|
|
103
|
+
message: `position must be a positive integer, got ${args[3]}`,
|
|
104
|
+
positionStart,
|
|
105
|
+
positionEnd,
|
|
106
|
+
hint: 'SQL uses 1-based indexing.',
|
|
107
|
+
rowNumber: rowIndex,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Default occurrence is 0 (replace all)
|
|
113
|
+
let occurrence = 0
|
|
114
|
+
if (args.length >= 5 && args[4] != null) {
|
|
115
|
+
occurrence = Number(args[4])
|
|
116
|
+
if (!Number.isInteger(occurrence) || occurrence < 0) {
|
|
117
|
+
throw argValueError({
|
|
118
|
+
funcName,
|
|
119
|
+
message: `occurrence must be a non-negative integer, got ${args[4]}`,
|
|
120
|
+
positionStart,
|
|
121
|
+
positionEnd,
|
|
122
|
+
hint: 'Use 0 to replace all occurrences.',
|
|
123
|
+
rowNumber: rowIndex,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create regex
|
|
129
|
+
let regex
|
|
130
|
+
try {
|
|
131
|
+
regex = new RegExp(patternStr, 'g')
|
|
132
|
+
} catch (/** @type {any} */ error) {
|
|
133
|
+
throw argValueError({
|
|
134
|
+
funcName,
|
|
135
|
+
message: `invalid regex pattern: ${error.message}`,
|
|
136
|
+
positionStart,
|
|
137
|
+
positionEnd,
|
|
138
|
+
rowNumber: rowIndex,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// If position > 1, preserve the prefix
|
|
143
|
+
const prefix = strVal.substring(0, position - 1)
|
|
144
|
+
const searchStr = strVal.substring(position - 1)
|
|
145
|
+
|
|
146
|
+
if (occurrence === 0) {
|
|
147
|
+
// Replace all occurrences
|
|
148
|
+
return prefix + searchStr.replace(regex, replacementStr)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Replace only the nth occurrence
|
|
152
|
+
let count = 0
|
|
153
|
+
const result = searchStr.replace(regex, (match) => {
|
|
154
|
+
count++
|
|
155
|
+
return count === occurrence ? replacementStr : match
|
|
156
|
+
})
|
|
157
|
+
return prefix + result
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { SqlPrimitive, StringFunc } from '../types.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { argValueError } from '../validationErrors.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Evaluate a string function
|
|
9
|
+
*
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {StringFunc} options.funcName - Uppercase function name
|
|
12
|
+
* @param {SqlPrimitive[]} options.args - Function arguments
|
|
13
|
+
* @param {number} [options.positionStart] - Start position for error reporting
|
|
14
|
+
* @param {number} [options.positionEnd] - End position for error reporting
|
|
15
|
+
* @param {number} [options.rowIndex] - Row index for error reporting
|
|
16
|
+
* @returns {SqlPrimitive} Result
|
|
17
|
+
*/
|
|
18
|
+
export function evaluateStringFunc({ funcName, args, positionStart, positionEnd, rowIndex }) {
|
|
19
|
+
if (funcName === 'UPPER') {
|
|
20
|
+
const val = args[0]
|
|
21
|
+
if (val == null) return null
|
|
22
|
+
return String(val).toUpperCase()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (funcName === 'LOWER') {
|
|
26
|
+
const val = args[0]
|
|
27
|
+
if (val == null) return null
|
|
28
|
+
return String(val).toLowerCase()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (funcName === 'CONCAT') {
|
|
32
|
+
// SQL CONCAT returns NULL if any argument is NULL
|
|
33
|
+
if (args.some(a => a == null)) return null
|
|
34
|
+
if (args.some(a => typeof a === 'object')) {
|
|
35
|
+
throw argValueError({
|
|
36
|
+
funcName: 'CONCAT',
|
|
37
|
+
message: 'does not support object arguments',
|
|
38
|
+
positionStart,
|
|
39
|
+
positionEnd,
|
|
40
|
+
hint: 'Use CAST to convert objects to strings first.',
|
|
41
|
+
rowNumber: rowIndex,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
return args.map(a => String(a)).join('')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (funcName === 'LENGTH') {
|
|
48
|
+
const val = args[0]
|
|
49
|
+
if (val == null) return null
|
|
50
|
+
return String(val).length
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (funcName === 'SUBSTRING' || funcName === 'SUBSTR') {
|
|
54
|
+
const str = args[0]
|
|
55
|
+
if (str == null) return null
|
|
56
|
+
const strVal = String(str)
|
|
57
|
+
const start = Number(args[1])
|
|
58
|
+
if (!Number.isInteger(start) || start < 1) {
|
|
59
|
+
throw argValueError({
|
|
60
|
+
funcName,
|
|
61
|
+
message: `start position must be a positive integer, got ${args[1]}`,
|
|
62
|
+
positionStart,
|
|
63
|
+
positionEnd,
|
|
64
|
+
hint: 'SQL uses 1-based indexing.',
|
|
65
|
+
rowNumber: rowIndex,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
// SQL uses 1-based indexing
|
|
69
|
+
const startIdx = start - 1
|
|
70
|
+
if (args.length === 3) {
|
|
71
|
+
const len = Number(args[2])
|
|
72
|
+
if (!Number.isInteger(len) || len < 0) {
|
|
73
|
+
throw argValueError({
|
|
74
|
+
funcName,
|
|
75
|
+
message: `length must be a non-negative integer, got ${args[2]}`,
|
|
76
|
+
positionStart,
|
|
77
|
+
positionEnd,
|
|
78
|
+
rowNumber: rowIndex,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
return strVal.substring(startIdx, startIdx + len)
|
|
82
|
+
}
|
|
83
|
+
return strVal.substring(startIdx)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (funcName === 'TRIM') {
|
|
87
|
+
const val = args[0]
|
|
88
|
+
if (val == null) return null
|
|
89
|
+
return String(val).trim()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (funcName === 'REPLACE') {
|
|
93
|
+
const str = args[0]
|
|
94
|
+
const searchStr = args[1]
|
|
95
|
+
const replaceStr = args[2]
|
|
96
|
+
// SQL REPLACE returns NULL if any argument is NULL
|
|
97
|
+
if (str == null || searchStr == null || replaceStr == null) return null
|
|
98
|
+
return String(str).replaceAll(String(searchStr), String(replaceStr))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (funcName === 'LEFT') {
|
|
102
|
+
const str = args[0]
|
|
103
|
+
const n = args[1]
|
|
104
|
+
if (str == null || n == null) return null
|
|
105
|
+
const len = Number(n)
|
|
106
|
+
if (!Number.isInteger(len) || len < 0) {
|
|
107
|
+
throw argValueError({
|
|
108
|
+
funcName,
|
|
109
|
+
message: `length must be a non-negative integer, got ${n}`,
|
|
110
|
+
positionStart,
|
|
111
|
+
positionEnd,
|
|
112
|
+
rowNumber: rowIndex,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return String(str).substring(0, len)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (funcName === 'RIGHT') {
|
|
119
|
+
const str = args[0]
|
|
120
|
+
const n = args[1]
|
|
121
|
+
if (str == null || n == null) return null
|
|
122
|
+
const len = Number(n)
|
|
123
|
+
if (!Number.isInteger(len) || len < 0) {
|
|
124
|
+
throw argValueError({
|
|
125
|
+
funcName,
|
|
126
|
+
message: `length must be a non-negative integer, got ${n}`,
|
|
127
|
+
positionStart,
|
|
128
|
+
positionEnd,
|
|
129
|
+
rowNumber: rowIndex,
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
const strVal = String(str)
|
|
133
|
+
if (len >= strVal.length) return strVal
|
|
134
|
+
return strVal.substring(strVal.length - len)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (funcName === 'INSTR') {
|
|
138
|
+
const str = args[0]
|
|
139
|
+
const search = args[1]
|
|
140
|
+
if (str == null || search == null) return null
|
|
141
|
+
// INSTR returns 1-based position, 0 if not found
|
|
142
|
+
const pos = String(str).indexOf(String(search))
|
|
143
|
+
return pos === -1 ? 0 : pos + 1
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { AsyncDataSource, AsyncRow, ExecuteSqlOptions, SelectStatement, SqlPrimitive } from './types.js'
|
|
2
|
-
export type { AsyncDataSource, AsyncRow, SqlPrimitive } from './types.js'
|
|
1
|
+
import type { AsyncDataSource, AsyncRow, ExecuteSqlOptions, ParseSqlOptions, SelectStatement, SqlPrimitive, Token } from './types.js'
|
|
2
|
+
export type { AsyncCells, AsyncDataSource, AsyncRow, ExprNode, ParseSqlOptions, SelectStatement, SqlPrimitive, Token, UserDefinedFunction } from './types.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Executes a SQL SELECT query against an array of data rows
|
|
@@ -7,6 +7,8 @@ export type { AsyncDataSource, AsyncRow, SqlPrimitive } from './types.js'
|
|
|
7
7
|
* @param options
|
|
8
8
|
* @param options.tables - source data as a list of objects or an AsyncDataSource
|
|
9
9
|
* @param options.query - SQL query string
|
|
10
|
+
* @param options.functions - user-defined functions available in the SQL context
|
|
11
|
+
* @param options.signal - AbortSignal to cancel the query
|
|
10
12
|
* @returns async generator yielding rows matching the query
|
|
11
13
|
*/
|
|
12
14
|
export function executeSql(options: ExecuteSqlOptions): AsyncGenerator<AsyncRow>
|
|
@@ -16,9 +18,18 @@ export function executeSql(options: ExecuteSqlOptions): AsyncGenerator<AsyncRow>
|
|
|
16
18
|
*
|
|
17
19
|
* @param options
|
|
18
20
|
* @param options.query - SQL query string to parse
|
|
21
|
+
* @param options.functions - user-defined functions available in the SQL context
|
|
19
22
|
* @returns parsed SQL select statement
|
|
20
23
|
*/
|
|
21
|
-
export function parseSql(options:
|
|
24
|
+
export function parseSql(options: ParseSqlOptions): SelectStatement
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tokenizes a SQL query string into an array of tokens
|
|
28
|
+
*
|
|
29
|
+
* @param sql - SQL query string to tokenize
|
|
30
|
+
* @returns array of tokens
|
|
31
|
+
*/
|
|
32
|
+
export function tokenizeSql(sql: string): Token[]
|
|
22
33
|
|
|
23
34
|
/**
|
|
24
35
|
* Collects all results from an async generator into an array
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { executeSql } from './execute/execute.js'
|
|
2
2
|
export { parseSql } from './parse/parse.js'
|
|
3
|
+
export { tokenizeSql } from './parse/tokenize.js'
|
|
3
4
|
export { collect } from './execute/utils.js'
|
|
4
5
|
export { cachedDataSource } from './backend/dataSource.js'
|
|
5
6
|
export { ParseError } from './parseErrors.js'
|
package/src/parse/expression.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
argCountParseError,
|
|
3
2
|
invalidLiteralError,
|
|
4
3
|
missingClauseError,
|
|
5
4
|
syntaxError,
|
|
6
5
|
unknownFunctionError,
|
|
7
6
|
} from '../parseErrors.js'
|
|
8
|
-
import { isIntervalUnit, isKnownFunction
|
|
7
|
+
import { isIntervalUnit, isKnownFunction } from '../validation.js'
|
|
9
8
|
import { parseComparison } from './comparison.js'
|
|
9
|
+
import { parseFunctionCall } from './functions.js'
|
|
10
10
|
import { parseSelectInternal } from './parse.js'
|
|
11
11
|
import { consume, current, expect, expectIdentifier, lastPosition, match, peekToken } from './state.js'
|
|
12
12
|
|
|
@@ -123,10 +123,9 @@ export function parsePrimary(state) {
|
|
|
123
123
|
// function call
|
|
124
124
|
if (next.type === 'paren' && next.value === '(') {
|
|
125
125
|
const funcName = tok.value
|
|
126
|
-
const funcNameUpper = funcName.toUpperCase()
|
|
127
126
|
|
|
128
127
|
// Validate function existence early for better error messages
|
|
129
|
-
if (!isKnownFunction(
|
|
128
|
+
if (!isKnownFunction(funcName.toUpperCase(), state.functions)) {
|
|
130
129
|
throw unknownFunctionError({
|
|
131
130
|
funcName,
|
|
132
131
|
positionStart,
|
|
@@ -135,62 +134,7 @@ export function parsePrimary(state) {
|
|
|
135
134
|
}
|
|
136
135
|
|
|
137
136
|
consume(state) // function name
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
/** @type {ExprNode[]} */
|
|
141
|
-
const args = []
|
|
142
|
-
let distinct = false
|
|
143
|
-
|
|
144
|
-
// Check for DISTINCT or ALL keyword (for aggregate functions like COUNT(DISTINCT x))
|
|
145
|
-
if (current(state).type === 'keyword' && current(state).value === 'DISTINCT') {
|
|
146
|
-
consume(state) // consume DISTINCT
|
|
147
|
-
distinct = true
|
|
148
|
-
} else if (current(state).type === 'keyword' && current(state).value === 'ALL') {
|
|
149
|
-
consume(state) // consume ALL (default behavior, just consume it)
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (current(state).type !== 'paren' || current(state).value !== ')') {
|
|
153
|
-
while (true) {
|
|
154
|
-
// Handle COUNT(*) - treat * as a special identifier
|
|
155
|
-
if (current(state).type === 'operator' && current(state).value === '*') {
|
|
156
|
-
const starTok = current(state)
|
|
157
|
-
consume(state)
|
|
158
|
-
args.push({
|
|
159
|
-
type: 'identifier',
|
|
160
|
-
name: '*',
|
|
161
|
-
positionStart: starTok.positionStart,
|
|
162
|
-
positionEnd: lastPosition(state),
|
|
163
|
-
})
|
|
164
|
-
} else {
|
|
165
|
-
const arg = parseExpression(state)
|
|
166
|
-
args.push(arg)
|
|
167
|
-
}
|
|
168
|
-
if (!match(state, 'comma')) break
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
expect(state, 'paren', ')')
|
|
173
|
-
|
|
174
|
-
// Validate argument count at parse time
|
|
175
|
-
const validation = validateFunctionArgCount(funcNameUpper, args.length)
|
|
176
|
-
if (!validation.valid) {
|
|
177
|
-
throw argCountParseError({
|
|
178
|
-
funcName,
|
|
179
|
-
expected: validation.expected,
|
|
180
|
-
received: args.length,
|
|
181
|
-
positionStart,
|
|
182
|
-
positionEnd: lastPosition(state),
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
type: 'function',
|
|
188
|
-
name: funcName,
|
|
189
|
-
args,
|
|
190
|
-
distinct: distinct || undefined,
|
|
191
|
-
positionStart,
|
|
192
|
-
positionEnd: lastPosition(state),
|
|
193
|
-
}
|
|
137
|
+
return parseFunctionCall(state, funcName, positionStart)
|
|
194
138
|
}
|
|
195
139
|
|
|
196
140
|
// Niladic datetime functions (no parentheses required per ANSI SQL)
|
|
@@ -244,7 +188,14 @@ export function parsePrimary(state) {
|
|
|
244
188
|
}
|
|
245
189
|
}
|
|
246
190
|
|
|
191
|
+
// Keywords that can be used as function names (e.g., LEFT, RIGHT)
|
|
247
192
|
if (tok.type === 'keyword') {
|
|
193
|
+
const next = peekToken(state, 1)
|
|
194
|
+
if (next.type === 'paren' && next.value === '(' && isKnownFunction(tok.value, state.functions)) {
|
|
195
|
+
consume(state) // function name
|
|
196
|
+
return parseFunctionCall(state, tok.value, positionStart)
|
|
197
|
+
}
|
|
198
|
+
|
|
248
199
|
if (tok.value === 'TRUE') {
|
|
249
200
|
consume(state)
|
|
250
201
|
return { type: 'literal', value: true, positionStart, positionEnd: lastPosition(state) }
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { argCountParseError } from '../parseErrors.js'
|
|
2
|
+
import { validateFunctionArgCount } from '../validation.js'
|
|
3
|
+
import { parseExpression } from './expression.js'
|
|
4
|
+
import { consume, current, expect, lastPosition, match } from './state.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @import { ExprNode, ParserState } from '../types.js'
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parses a function call after the function name has been identified.
|
|
12
|
+
* Expects the current token to be '('.
|
|
13
|
+
*
|
|
14
|
+
* @param {ParserState} state
|
|
15
|
+
* @param {string} funcName - The function name
|
|
16
|
+
* @param {number} positionStart - Start position of the function name
|
|
17
|
+
* @returns {ExprNode}
|
|
18
|
+
*/
|
|
19
|
+
export function parseFunctionCall(state, funcName, positionStart) {
|
|
20
|
+
consume(state) // '('
|
|
21
|
+
|
|
22
|
+
/** @type {ExprNode[]} */
|
|
23
|
+
const args = []
|
|
24
|
+
let distinct = false
|
|
25
|
+
|
|
26
|
+
// Check for DISTINCT or ALL keyword (for aggregate functions like COUNT(DISTINCT x))
|
|
27
|
+
if (current(state).type === 'keyword' && current(state).value === 'DISTINCT') {
|
|
28
|
+
consume(state)
|
|
29
|
+
distinct = true
|
|
30
|
+
} else if (current(state).type === 'keyword' && current(state).value === 'ALL') {
|
|
31
|
+
consume(state)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (current(state).type !== 'paren' || current(state).value !== ')') {
|
|
35
|
+
while (true) {
|
|
36
|
+
// Handle COUNT(*) - treat * as a special identifier
|
|
37
|
+
if (current(state).type === 'operator' && current(state).value === '*') {
|
|
38
|
+
const starTok = current(state)
|
|
39
|
+
consume(state)
|
|
40
|
+
args.push({
|
|
41
|
+
type: 'identifier',
|
|
42
|
+
name: '*',
|
|
43
|
+
positionStart: starTok.positionStart,
|
|
44
|
+
positionEnd: lastPosition(state),
|
|
45
|
+
})
|
|
46
|
+
} else {
|
|
47
|
+
args.push(parseExpression(state))
|
|
48
|
+
}
|
|
49
|
+
if (!match(state, 'comma')) break
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
expect(state, 'paren', ')')
|
|
54
|
+
|
|
55
|
+
// Validate argument count at parse time
|
|
56
|
+
const funcNameUpper = funcName.toUpperCase()
|
|
57
|
+
const validation = validateFunctionArgCount(funcNameUpper, args.length, state.functions)
|
|
58
|
+
if (!validation.valid) {
|
|
59
|
+
throw argCountParseError({
|
|
60
|
+
funcName,
|
|
61
|
+
expected: validation.expected,
|
|
62
|
+
received: args.length,
|
|
63
|
+
positionStart,
|
|
64
|
+
positionEnd: lastPosition(state),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
type: 'function',
|
|
70
|
+
name: funcName,
|
|
71
|
+
args,
|
|
72
|
+
distinct: distinct || undefined,
|
|
73
|
+
positionStart,
|
|
74
|
+
positionEnd: lastPosition(state),
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/parse/joins.js
CHANGED
|
@@ -40,6 +40,9 @@ export function parseJoins(state) {
|
|
|
40
40
|
// FULL OUTER JOIN
|
|
41
41
|
}
|
|
42
42
|
joinType = 'FULL'
|
|
43
|
+
} else if (tok.value === 'POSITIONAL') {
|
|
44
|
+
consume(state)
|
|
45
|
+
joinType = 'POSITIONAL'
|
|
43
46
|
} else if (tok.value === 'JOIN') {
|
|
44
47
|
// Just JOIN (defaults to INNER)
|
|
45
48
|
consume(state)
|
|
@@ -61,9 +64,13 @@ export function parseJoins(state) {
|
|
|
61
64
|
const tableName = expectIdentifier(state).value
|
|
62
65
|
const tableAlias = parseTableAlias(state)
|
|
63
66
|
|
|
64
|
-
// Parse ON condition
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// Parse ON condition (not for POSITIONAL joins)
|
|
68
|
+
/** @type {import('../types.js').ExprNode | undefined} */
|
|
69
|
+
let condition
|
|
70
|
+
if (joinType !== 'POSITIONAL') {
|
|
71
|
+
expect(state, 'keyword', 'ON')
|
|
72
|
+
condition = parseExpression(state)
|
|
73
|
+
}
|
|
67
74
|
|
|
68
75
|
joins.push({
|
|
69
76
|
joinType,
|
package/src/parse/parse.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { tokenizeSql } from './tokenize.js'
|
|
2
2
|
import { parseExpression } from './expression.js'
|
|
3
|
-
import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE } from '../validation.js'
|
|
3
|
+
import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE, isKnownFunction } from '../validation.js'
|
|
4
4
|
import { consume, current, expect, expectIdentifier, match, parseError, peekToken } from './state.js'
|
|
5
5
|
import { parseJoins } from './joins.js'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* @import { ExprNode, FromSubquery, FromTable, OrderByItem, ParserState, SelectStatement, SelectColumn
|
|
8
|
+
* @import { ExprNode, FromSubquery, FromTable, OrderByItem, ParseSqlOptions, ParserState, SelectStatement, SelectColumn } from '../types.js'
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @param {
|
|
12
|
+
* @param {ParseSqlOptions} options
|
|
13
13
|
* @returns {SelectStatement}
|
|
14
14
|
*/
|
|
15
15
|
export function parseSql({ query, functions }) {
|
|
16
|
-
const tokens =
|
|
16
|
+
const tokens = tokenizeSql(query)
|
|
17
17
|
/** @type {ParserState} */
|
|
18
18
|
const state = { tokens, pos: 0, functions }
|
|
19
19
|
const select = parseSelectInternal(state)
|
|
@@ -75,7 +75,13 @@ const EXPRESSION_START_KEYWORDS = new Set([
|
|
|
75
75
|
function parseSelectItem(state) {
|
|
76
76
|
const tok = current(state)
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
// Check if keyword followed by ( is a known function (e.g., LEFT, RIGHT)
|
|
79
|
+
const isKeywordFunction = tok.type === 'keyword' &&
|
|
80
|
+
peekToken(state, 1).type === 'paren' &&
|
|
81
|
+
peekToken(state, 1).value === '(' &&
|
|
82
|
+
isKnownFunction(tok.value, state.functions)
|
|
83
|
+
|
|
84
|
+
if (tok.type === 'keyword' && !EXPRESSION_START_KEYWORDS.has(tok.value) && !isKeywordFunction || tok.type === 'eof') {
|
|
79
85
|
throw parseError(state, 'column name or expression')
|
|
80
86
|
}
|
|
81
87
|
|
package/src/parse/tokenize.js
CHANGED
|
@@ -46,6 +46,7 @@ const KEYWORDS = new Set([
|
|
|
46
46
|
'RIGHT',
|
|
47
47
|
'FULL',
|
|
48
48
|
'OUTER',
|
|
49
|
+
'POSITIONAL',
|
|
49
50
|
'ON',
|
|
50
51
|
'INTERVAL',
|
|
51
52
|
'DAY',
|
|
@@ -60,7 +61,7 @@ const KEYWORDS = new Set([
|
|
|
60
61
|
* @param {string} sql
|
|
61
62
|
* @returns {Token[]}
|
|
62
63
|
*/
|
|
63
|
-
export function
|
|
64
|
+
export function tokenizeSql(sql) {
|
|
64
65
|
/** @type {Token[]} */
|
|
65
66
|
const tokens = []
|
|
66
67
|
const { length } = sql
|
package/src/types.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
export
|
|
1
|
+
// parseSql(options)
|
|
2
|
+
export interface ParseSqlOptions {
|
|
3
|
+
query: string
|
|
4
|
+
functions?: Record<string, UserDefinedFunction>
|
|
5
|
+
}
|
|
3
6
|
|
|
4
7
|
// executeSql(options)
|
|
5
8
|
export interface ExecuteSqlOptions {
|
|
@@ -56,6 +59,14 @@ export type SqlPrimitive =
|
|
|
56
59
|
| SqlPrimitive[]
|
|
57
60
|
| Record<string, any>
|
|
58
61
|
|
|
62
|
+
export interface UserDefinedFunction {
|
|
63
|
+
apply: (...args: SqlPrimitive[]) => SqlPrimitive | Promise<SqlPrimitive>
|
|
64
|
+
arguments: {
|
|
65
|
+
min: number
|
|
66
|
+
max?: number
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
59
70
|
export interface SelectStatement {
|
|
60
71
|
distinct: boolean
|
|
61
72
|
columns: SelectColumn[]
|
|
@@ -214,6 +225,8 @@ export type MathFunc =
|
|
|
214
225
|
| 'DEGREES'
|
|
215
226
|
| 'RADIANS'
|
|
216
227
|
| 'PI'
|
|
228
|
+
| 'RAND'
|
|
229
|
+
| 'RANDOM'
|
|
217
230
|
|
|
218
231
|
export type StringFunc =
|
|
219
232
|
| 'UPPER'
|
|
@@ -224,12 +237,9 @@ export type StringFunc =
|
|
|
224
237
|
| 'SUBSTR'
|
|
225
238
|
| 'TRIM'
|
|
226
239
|
| 'REPLACE'
|
|
227
|
-
| '
|
|
228
|
-
| '
|
|
229
|
-
| '
|
|
230
|
-
| 'CURRENT_DATE'
|
|
231
|
-
| 'CURRENT_TIME'
|
|
232
|
-
| 'CURRENT_TIMESTAMP'
|
|
240
|
+
| 'LEFT'
|
|
241
|
+
| 'RIGHT'
|
|
242
|
+
| 'INSTR'
|
|
233
243
|
|
|
234
244
|
export interface DerivedColumn {
|
|
235
245
|
kind: 'derived'
|
|
@@ -245,7 +255,7 @@ export interface OrderByItem {
|
|
|
245
255
|
nulls?: 'FIRST' | 'LAST'
|
|
246
256
|
}
|
|
247
257
|
|
|
248
|
-
export type JoinType = 'INNER' | 'LEFT' | 'RIGHT' | 'FULL' | 'CROSS'
|
|
258
|
+
export type JoinType = 'INNER' | 'LEFT' | 'RIGHT' | 'FULL' | 'CROSS' | 'POSITIONAL'
|
|
249
259
|
|
|
250
260
|
export interface JoinClause {
|
|
251
261
|
joinType: JoinType
|
package/src/validation.js
CHANGED
|
@@ -8,16 +8,23 @@ export function isAggregateFunc(name) {
|
|
|
8
8
|
return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG'].includes(name)
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} name
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isRegexpFunc(name) {
|
|
16
|
+
return ['REGEXP_SUBSTR', 'REGEXP_REPLACE'].includes(name)
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* @param {string} name
|
|
13
21
|
* @returns {name is MathFunc}
|
|
14
22
|
*/
|
|
15
23
|
export function isMathFunc(name) {
|
|
16
24
|
return [
|
|
17
|
-
'FLOOR', 'CEIL', 'CEILING', 'ABS', 'MOD',
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'DEGREES', 'RADIANS', 'PI',
|
|
25
|
+
'FLOOR', 'CEIL', 'CEILING', 'ABS', 'MOD', 'EXP', 'LN', 'LOG10', 'POWER', 'SQRT',
|
|
26
|
+
'SIN', 'COS', 'TAN', 'COT', 'ASIN', 'ACOS', 'ATAN', 'ATAN2', 'DEGREES', 'RADIANS', 'PI',
|
|
27
|
+
'RAND', 'RANDOM',
|
|
21
28
|
].includes(name)
|
|
22
29
|
}
|
|
23
30
|
|
|
@@ -35,22 +42,8 @@ export function isIntervalUnit(name) {
|
|
|
35
42
|
*/
|
|
36
43
|
export function isStringFunc(name) {
|
|
37
44
|
return [
|
|
38
|
-
'UPPER',
|
|
39
|
-
'
|
|
40
|
-
'CONCAT',
|
|
41
|
-
'LENGTH',
|
|
42
|
-
'SUBSTRING',
|
|
43
|
-
'SUBSTR',
|
|
44
|
-
'TRIM',
|
|
45
|
-
'REPLACE',
|
|
46
|
-
'RANDOM',
|
|
47
|
-
'RAND',
|
|
48
|
-
'JSON_VALUE',
|
|
49
|
-
'JSON_QUERY',
|
|
50
|
-
'JSON_OBJECT',
|
|
51
|
-
'CURRENT_DATE',
|
|
52
|
-
'CURRENT_TIME',
|
|
53
|
-
'CURRENT_TIMESTAMP',
|
|
45
|
+
'UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM',
|
|
46
|
+
'REPLACE', 'LEFT', 'RIGHT', 'INSTR',
|
|
54
47
|
].includes(name)
|
|
55
48
|
}
|
|
56
49
|
|
|
@@ -65,8 +58,8 @@ export function isBinaryOp(op) {
|
|
|
65
58
|
/**
|
|
66
59
|
* Function argument count specifications.
|
|
67
60
|
* min: minimum number of arguments
|
|
68
|
-
* max: maximum number of arguments
|
|
69
|
-
* @type {Record<string, {min: number, max
|
|
61
|
+
* max: maximum number of arguments
|
|
62
|
+
* @type {Record<string, {min: number, max?: number}>}
|
|
70
63
|
*/
|
|
71
64
|
export const FUNCTION_ARG_COUNTS = {
|
|
72
65
|
// String functions
|
|
@@ -77,7 +70,12 @@ export const FUNCTION_ARG_COUNTS = {
|
|
|
77
70
|
REPLACE: { min: 3, max: 3 },
|
|
78
71
|
SUBSTRING: { min: 2, max: 3 },
|
|
79
72
|
SUBSTR: { min: 2, max: 3 },
|
|
80
|
-
CONCAT: { min: 1
|
|
73
|
+
CONCAT: { min: 1 },
|
|
74
|
+
LEFT: { min: 2, max: 2 },
|
|
75
|
+
RIGHT: { min: 2, max: 2 },
|
|
76
|
+
INSTR: { min: 2, max: 2 },
|
|
77
|
+
REGEXP_SUBSTR: { min: 2, max: 4 },
|
|
78
|
+
REGEXP_REPLACE: { min: 3, max: 5 },
|
|
81
79
|
|
|
82
80
|
// Date/time functions
|
|
83
81
|
RANDOM: { min: 0, max: 0 },
|
|
@@ -112,9 +110,12 @@ export const FUNCTION_ARG_COUNTS = {
|
|
|
112
110
|
// JSON functions
|
|
113
111
|
JSON_VALUE: { min: 2, max: 2 },
|
|
114
112
|
JSON_QUERY: { min: 2, max: 2 },
|
|
115
|
-
JSON_OBJECT: { min: 0
|
|
113
|
+
JSON_OBJECT: { min: 0 },
|
|
116
114
|
JSON_ARRAYAGG: { min: 1, max: 1 },
|
|
117
115
|
|
|
116
|
+
// Conditional functions
|
|
117
|
+
COALESCE: { min: 1 },
|
|
118
|
+
|
|
118
119
|
// Aggregate functions
|
|
119
120
|
COUNT: { min: 1, max: 1 },
|
|
120
121
|
SUM: { min: 1, max: 1 },
|
|
@@ -126,11 +127,11 @@ export const FUNCTION_ARG_COUNTS = {
|
|
|
126
127
|
/**
|
|
127
128
|
* Format expected argument count for error messages.
|
|
128
129
|
* @param {number} min
|
|
129
|
-
* @param {number |
|
|
130
|
+
* @param {number | undefined} max
|
|
130
131
|
* @returns {string | number}
|
|
131
132
|
*/
|
|
132
133
|
function formatExpected(min, max) {
|
|
133
|
-
if (max
|
|
134
|
+
if (max == null) return `at least ${min}`
|
|
134
135
|
if (min === max) return min
|
|
135
136
|
return `${min} or ${max}`
|
|
136
137
|
}
|
|
@@ -139,10 +140,21 @@ function formatExpected(min, max) {
|
|
|
139
140
|
* Validates function argument count.
|
|
140
141
|
* @param {string} funcName - The function name (uppercase)
|
|
141
142
|
* @param {number} argCount - Number of arguments provided
|
|
143
|
+
* @param {Record<string, UserDefinedFunction>} [functions] - User-defined functions
|
|
142
144
|
* @returns {{ valid: boolean, expected: string | number }}
|
|
143
145
|
*/
|
|
144
|
-
export function validateFunctionArgCount(funcName, argCount) {
|
|
145
|
-
|
|
146
|
+
export function validateFunctionArgCount(funcName, argCount, functions) {
|
|
147
|
+
// Check built-in functions
|
|
148
|
+
let spec = FUNCTION_ARG_COUNTS[funcName]
|
|
149
|
+
|
|
150
|
+
// Check user-defined functions (case-insensitive)
|
|
151
|
+
if (!spec && functions) {
|
|
152
|
+
const udfName = Object.keys(functions).find(k => k.toUpperCase() === funcName)
|
|
153
|
+
if (udfName) {
|
|
154
|
+
spec = functions[udfName].arguments
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
146
158
|
if (!spec) return { valid: true, expected: 0 }
|
|
147
159
|
|
|
148
160
|
const { min, max } = spec
|
|
@@ -150,7 +162,7 @@ export function validateFunctionArgCount(funcName, argCount) {
|
|
|
150
162
|
if (argCount < min) {
|
|
151
163
|
return { valid: false, expected: formatExpected(min, max) }
|
|
152
164
|
}
|
|
153
|
-
if (max
|
|
165
|
+
if (max != null && argCount > max) {
|
|
154
166
|
return { valid: false, expected: formatExpected(min, max) }
|
|
155
167
|
}
|
|
156
168
|
|
|
@@ -165,12 +177,21 @@ export function validateFunctionArgCount(funcName, argCount) {
|
|
|
165
177
|
*/
|
|
166
178
|
export function isKnownFunction(funcName, functions) {
|
|
167
179
|
// Check built-in functions
|
|
168
|
-
if (
|
|
180
|
+
if (
|
|
181
|
+
isAggregateFunc(funcName) ||
|
|
182
|
+
isMathFunc(funcName) ||
|
|
183
|
+
isStringFunc(funcName) ||
|
|
184
|
+
isRegexpFunc(funcName)
|
|
185
|
+
) {
|
|
169
186
|
return true
|
|
170
187
|
}
|
|
171
188
|
|
|
172
|
-
//
|
|
173
|
-
if (
|
|
189
|
+
// Date/time, JSON, conditional, and CAST functions
|
|
190
|
+
if ([
|
|
191
|
+
'CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP',
|
|
192
|
+
'JSON_VALUE', 'JSON_QUERY', 'JSON_OBJECT',
|
|
193
|
+
'COALESCE', 'CAST',
|
|
194
|
+
].includes(funcName)) {
|
|
174
195
|
return true
|
|
175
196
|
}
|
|
176
197
|
|
|
@@ -190,5 +211,5 @@ export const RESERVED_AFTER_COLUMN = new Set([
|
|
|
190
211
|
// Keywords that cannot be used as table aliases
|
|
191
212
|
export const RESERVED_AFTER_TABLE = new Set([
|
|
192
213
|
'WHERE', 'GROUP', 'HAVING', 'ORDER', 'LIMIT', 'OFFSET', 'JOIN', 'INNER',
|
|
193
|
-
'LEFT', 'RIGHT', 'FULL', 'CROSS', 'ON',
|
|
214
|
+
'LEFT', 'RIGHT', 'FULL', 'CROSS', 'ON', 'POSITIONAL',
|
|
194
215
|
])
|
package/src/validationErrors.js
CHANGED
|
@@ -19,6 +19,9 @@ export const FUNCTION_SIGNATURES = {
|
|
|
19
19
|
SUBSTRING: 'string, start[, length]',
|
|
20
20
|
SUBSTR: 'string, start[, length]',
|
|
21
21
|
CONCAT: 'value1, value2[, ...]',
|
|
22
|
+
LEFT: 'string, length',
|
|
23
|
+
RIGHT: 'string, length',
|
|
24
|
+
INSTR: 'string, substring',
|
|
22
25
|
|
|
23
26
|
// Date/time functions
|
|
24
27
|
RANDOM: '',
|