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 CHANGED
@@ -10,7 +10,7 @@
10
10
  ![coverage](https://img.shields.io/badge/Coverage-95-darkred)
11
11
  [![dependencies](https://img.shields.io/badge/Dependencies-0-blueviolet)](https://www.npmjs.com/package/squirreling?activeTab=dependencies)
12
12
 
13
- Squirreling is a streaming async SQL engine for JavaScript. It is designed to provide efficient streaming of results from pluggable backends for highly efficient retrieval of data for browser applications.
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
- - Aggregate functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
85
- - String functions: `CONCAT`, `SUBSTRING`, `LENGTH`, `UPPER`, `LOWER`
86
- - Math functions: `ABS`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`, `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
87
- - Date functions: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
88
- - Json functions: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
89
- - Basic expressions and arithmetic operations
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.0",
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": "24.10.4",
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
- aggregateError,
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, IntervalUnit, UserDefinedFunction } from '../types.js'
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 === 'UPPER') {
204
- const val = args[0]
205
- if (val == null) return null
206
- return String(val).toUpperCase()
207
- }
208
-
209
- if (funcName === 'LOWER') {
210
- const val = args[0]
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 === 'LENGTH') {
232
- const val = args[0]
233
- if (val == null) return null
234
- return String(val).length
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 === 'SUBSTRING' || funcName === 'SUBSTR') {
238
- const str = args[0]
239
- if (str == null) return null
240
- const strVal = String(str)
241
- const start = Number(args[1])
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 strVal.substring(startIdx)
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
 
@@ -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
- yield* hashJoin({
47
- leftRows: leftSource.scan(options), // Stream directly, not buffered
48
- rightRows,
49
- join,
50
- leftTable: currentLeftTable,
51
- rightTable,
52
- tables,
53
- functions,
54
- signal,
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 = hashJoin({
88
- leftRows,
89
- rightRows,
90
- join,
91
- leftTable: currentLeftTable,
92
- rightTable,
93
- tables,
94
- functions,
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
- yield* hashJoin({
125
- leftRows,
126
- rightRows,
127
- join,
128
- leftTable: currentLeftTable,
129
- rightTable,
130
- tables,
131
- functions,
132
- signal,
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.
@@ -6,9 +6,9 @@
6
6
  * Evaluate a math function
7
7
  *
8
8
  * @param {Object} options
9
- * @param {MathFunc} options.funcName - Uppercase function name
10
- * @param {SqlPrimitive[]} options.args - Function arguments
11
- * @returns {SqlPrimitive} Result
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: { query: string }): SelectStatement
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'
@@ -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, validateFunctionArgCount } from '../validation.js'
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(funcNameUpper, state.functions)) {
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
- consume(state) // '('
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
+ }
@@ -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
- expect(state, 'keyword', 'ON')
66
- const condition = parseExpression(state)
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,
@@ -1,19 +1,19 @@
1
- import { tokenize } from './tokenize.js'
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, UserDefinedFunction } from '../types.js'
8
+ * @import { ExprNode, FromSubquery, FromTable, OrderByItem, ParseSqlOptions, ParserState, SelectStatement, SelectColumn } from '../types.js'
9
9
  */
10
10
 
11
11
  /**
12
- * @param {{ query: string, functions?: Record<string, UserDefinedFunction> }} options
12
+ * @param {ParseSqlOptions} options
13
13
  * @returns {SelectStatement}
14
14
  */
15
15
  export function parseSql({ query, functions }) {
16
- const tokens = tokenize(query)
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
- if (tok.type === 'keyword' && !EXPRESSION_START_KEYWORDS.has(tok.value) || tok.type === 'eof') {
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
 
@@ -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 tokenize(sql) {
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
- // User-defined function type
2
- export type UserDefinedFunction = (...args: SqlPrimitive[]) => SqlPrimitive | Promise<SqlPrimitive>
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
- | 'JSON_VALUE'
228
- | 'JSON_QUERY'
229
- | 'JSON_OBJECT'
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
- 'EXP', 'LN', 'LOG10', 'POWER', 'SQRT',
19
- 'SIN', 'COS', 'TAN', 'COT', 'ASIN', 'ACOS', 'ATAN', 'ATAN2',
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
- 'LOWER',
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 (null = unlimited)
69
- * @type {Record<string, {min: number, max: number | null}>}
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, max: null },
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, max: null },
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 | null} max
130
+ * @param {number | undefined} max
130
131
  * @returns {string | number}
131
132
  */
132
133
  function formatExpected(min, max) {
133
- if (max === null) return `at least ${min}`
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
- const spec = FUNCTION_ARG_COUNTS[funcName]
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 !== null && argCount > 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 (isAggregateFunc(funcName) || isMathFunc(funcName) || isStringFunc(funcName)) {
180
+ if (
181
+ isAggregateFunc(funcName) ||
182
+ isMathFunc(funcName) ||
183
+ isStringFunc(funcName) ||
184
+ isRegexpFunc(funcName)
185
+ ) {
169
186
  return true
170
187
  }
171
188
 
172
- // Special case: CAST is not in any function list but is a built-in
173
- if (funcName === 'CAST') {
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
  ])
@@ -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: '',