squirreling 0.4.7 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/errors.js +230 -0
- package/src/execute/aggregates.js +6 -5
- package/src/execute/date.js +57 -0
- package/src/execute/execute.js +10 -3
- package/src/execute/expression.js +87 -23
- package/src/execute/having.js +2 -1
- package/src/execute/join.js +8 -4
- package/src/execute/utils.js +3 -0
- package/src/parse/comparison.js +3 -2
- package/src/parse/expression.js +76 -5
- package/src/parse/parse.js +6 -1
- package/src/parse/state.js +5 -3
- package/src/parse/tokenize.js +89 -37
- package/src/types.d.ts +22 -2
- package/src/validation.js +12 -1
package/README.md
CHANGED
|
@@ -76,5 +76,6 @@ console.log(allUsers)
|
|
|
76
76
|
- `GROUP BY` and `HAVING` clauses
|
|
77
77
|
- Aggregate functions: `COUNT`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG`
|
|
78
78
|
- String functions: `CONCAT`, `SUBSTRING`, `LENGTH`, `UPPER`, `LOWER`
|
|
79
|
+
- Date functions: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `INTERVAL`
|
|
79
80
|
- Json functions: `JSON_VALUE`, `JSON_QUERY`, `JSON_OBJECT`
|
|
80
81
|
- Basic expressions and arithmetic operations
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squirreling",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
4
4
|
"description": "Squirreling SQL Engine",
|
|
5
5
|
"author": "Hyperparam",
|
|
6
6
|
"homepage": "https://hyperparam.app",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@types/node": "24.10.1",
|
|
41
41
|
"@vitest/coverage-v8": "4.0.15",
|
|
42
42
|
"eslint": "9.39.1",
|
|
43
|
-
"eslint-plugin-jsdoc": "61.4.
|
|
43
|
+
"eslint-plugin-jsdoc": "61.4.2",
|
|
44
44
|
"typescript": "5.9.3",
|
|
45
45
|
"vitest": "4.0.15"
|
|
46
46
|
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// PARSE ERRORS - Issues during SQL tokenization and parsing
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* General syntax error for unexpected tokens.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} options
|
|
9
|
+
* @param {string} options.expected - Description of what was expected
|
|
10
|
+
* @param {string} options.received - What was actually found
|
|
11
|
+
* @param {number} options.position - Character position in query
|
|
12
|
+
* @param {string} [options.after] - What token came before (for context)
|
|
13
|
+
* @returns {Error}
|
|
14
|
+
*/
|
|
15
|
+
export function syntaxError({ expected, received, position, after }) {
|
|
16
|
+
const afterClause = after ? ` after "${after}"` : ''
|
|
17
|
+
return new Error(`Expected ${expected}${afterClause} but found ${received} at position ${position}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Error for unterminated literals (strings, identifiers).
|
|
22
|
+
*
|
|
23
|
+
* @param {'string' | 'identifier'} type - Type of unterminated literal
|
|
24
|
+
* @param {number} position - Starting position
|
|
25
|
+
* @returns {Error}
|
|
26
|
+
*/
|
|
27
|
+
export function unterminatedError(type, position) {
|
|
28
|
+
const name = type === 'string' ? 'string literal' : 'identifier'
|
|
29
|
+
return new Error(`Unterminated ${name} starting at position ${position}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Error for invalid literals (numbers, intervals, etc).
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} options
|
|
36
|
+
* @param {string} options.type - Type of invalid literal (e.g., 'number', 'interval value', 'interval unit')
|
|
37
|
+
* @param {string} options.value - The invalid value
|
|
38
|
+
* @param {number} options.position - Position in query
|
|
39
|
+
* @param {string} [options.validValues] - List of valid values (for enums like interval units)
|
|
40
|
+
* @returns {Error}
|
|
41
|
+
*/
|
|
42
|
+
export function invalidLiteralError({ type, value, position, validValues }) {
|
|
43
|
+
const suffix = validValues ? `. Valid values: ${validValues}` : ''
|
|
44
|
+
return new Error(`Invalid ${type} ${value} at position ${position}${suffix}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Error for unexpected characters during tokenization.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} char - The unexpected character
|
|
51
|
+
* @param {number} position - Position in query
|
|
52
|
+
* @param {boolean} [expectsSelect=false] - Whether SELECT was expected (first token)
|
|
53
|
+
* @returns {Error}
|
|
54
|
+
*/
|
|
55
|
+
export function unexpectedCharError(char, position, expectsSelect = false) {
|
|
56
|
+
if (expectsSelect) {
|
|
57
|
+
return new Error(`Expected SELECT but found "${char}" at position ${position}. Queries must start with SELECT.`)
|
|
58
|
+
}
|
|
59
|
+
return new Error(`Unexpected character "${char}" at position ${position}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Error for unknown/unsupported functions.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} funcName - The unknown function name
|
|
66
|
+
* @param {number} [position] - Position in query (for parse errors)
|
|
67
|
+
* @param {string} [validFunctions] - List of valid functions
|
|
68
|
+
* @returns {Error}
|
|
69
|
+
*/
|
|
70
|
+
export function unknownFunctionError(funcName, position, validFunctions) {
|
|
71
|
+
const supported = validFunctions ||
|
|
72
|
+
'COUNT, SUM, AVG, MIN, MAX, UPPER, LOWER, CONCAT, LENGTH, SUBSTRING, TRIM, REPLACE, JSON_OBJECT, JSON_VALUE, JSON_QUERY, JSON_ARRAYAGG'
|
|
73
|
+
|
|
74
|
+
if (position !== undefined) {
|
|
75
|
+
return new Error(`Unknown function "${funcName}" at position ${position}. Supported: ${supported}`)
|
|
76
|
+
}
|
|
77
|
+
return new Error(`Unsupported function: ${funcName}. Supported: ${supported}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Error for missing required clause or structure.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} options
|
|
84
|
+
* @param {string} options.missing - What is missing (e.g., 'WHEN clause', 'FROM clause', 'ON condition')
|
|
85
|
+
* @param {string} options.context - Where it's missing from (e.g., 'CASE expression', 'SELECT statement', 'JOIN')
|
|
86
|
+
* @returns {Error}
|
|
87
|
+
*/
|
|
88
|
+
export function missingClauseError({ missing, context }) {
|
|
89
|
+
return new Error(`${context} requires ${missing}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// EXECUTION ERRORS - Issues during query execution
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Error for missing table.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} tableName - The missing table name
|
|
100
|
+
* @returns {Error}
|
|
101
|
+
*/
|
|
102
|
+
export function tableNotFoundError(tableName) {
|
|
103
|
+
return new Error(`Table "${tableName}" not found. Check spelling or add it to the tables parameter.`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Error for invalid context (e.g., INTERVAL without date arithmetic).
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} options
|
|
110
|
+
* @param {string} options.item - What was used incorrectly
|
|
111
|
+
* @param {string} options.validContext - Where it can be used
|
|
112
|
+
* @returns {Error}
|
|
113
|
+
*/
|
|
114
|
+
export function invalidContextError({ item, validContext }) {
|
|
115
|
+
return new Error(`${item} can only be used with ${validContext}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Error for unsupported operation combinations.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} operation - The unsupported operation
|
|
122
|
+
* @param {string} [hint] - How to fix it
|
|
123
|
+
* @returns {Error}
|
|
124
|
+
*/
|
|
125
|
+
export function unsupportedOperationError(operation, hint) {
|
|
126
|
+
const suffix = hint ? `. ${hint}` : ''
|
|
127
|
+
return new Error(`${operation}${suffix}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// VALIDATION ERRORS - Function argument and type validation
|
|
132
|
+
// ============================================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Function signatures for helpful error messages.
|
|
136
|
+
* Maps function name to its parameter signature.
|
|
137
|
+
* @type {Record<string, string>}
|
|
138
|
+
*/
|
|
139
|
+
const FUNCTION_SIGNATURES = {
|
|
140
|
+
// String functions
|
|
141
|
+
UPPER: 'string',
|
|
142
|
+
LOWER: 'string',
|
|
143
|
+
LENGTH: 'string',
|
|
144
|
+
TRIM: 'string',
|
|
145
|
+
REPLACE: 'string, search, replacement',
|
|
146
|
+
SUBSTRING: 'string, start[, length]',
|
|
147
|
+
SUBSTR: 'string, start[, length]',
|
|
148
|
+
CONCAT: 'value1, value2[, ...]',
|
|
149
|
+
|
|
150
|
+
// Date/time functions
|
|
151
|
+
RANDOM: '',
|
|
152
|
+
RAND: '',
|
|
153
|
+
CURRENT_DATE: '',
|
|
154
|
+
CURRENT_TIME: '',
|
|
155
|
+
CURRENT_TIMESTAMP: '',
|
|
156
|
+
|
|
157
|
+
// JSON functions
|
|
158
|
+
JSON_VALUE: 'expression, path',
|
|
159
|
+
JSON_QUERY: 'expression, path',
|
|
160
|
+
JSON_OBJECT: 'key1, value1[, ...]',
|
|
161
|
+
JSON_ARRAYAGG: 'expression',
|
|
162
|
+
|
|
163
|
+
// Aggregate functions
|
|
164
|
+
COUNT: 'expression',
|
|
165
|
+
SUM: 'expression',
|
|
166
|
+
AVG: 'expression',
|
|
167
|
+
MIN: 'expression',
|
|
168
|
+
MAX: 'expression',
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Error for wrong number of function arguments.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} funcName - The function name
|
|
175
|
+
* @param {number | string} expected - Expected count (number or range like "2 or 3")
|
|
176
|
+
* @param {number} received - Actual argument count
|
|
177
|
+
* @returns {Error}
|
|
178
|
+
*/
|
|
179
|
+
export function argCountError(funcName, expected, received) {
|
|
180
|
+
const signature = FUNCTION_SIGNATURES[funcName] ?? ''
|
|
181
|
+
let expectedStr = `${expected} arguments`
|
|
182
|
+
if (expected === 0) expectedStr = 'no arguments'
|
|
183
|
+
if (expected === 1) expectedStr = '1 argument'
|
|
184
|
+
if (typeof expected === 'string' && expected.endsWith(' 1')) {
|
|
185
|
+
expectedStr = `${expected} argument`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return new Error(`${funcName}(${signature}) function requires ${expectedStr}, got ${received}`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Error for invalid argument type or value.
|
|
193
|
+
*
|
|
194
|
+
* @param {Object} options
|
|
195
|
+
* @param {string} options.funcName - The function name
|
|
196
|
+
* @param {string} options.message - Specific error message
|
|
197
|
+
* @param {string} [options.hint] - Recovery hint
|
|
198
|
+
* @returns {Error}
|
|
199
|
+
*/
|
|
200
|
+
export function argValueError({ funcName, message, hint }) {
|
|
201
|
+
const signature = FUNCTION_SIGNATURES[funcName] ?? ''
|
|
202
|
+
const suffix = hint ? `. ${hint}` : ''
|
|
203
|
+
return new Error(`${funcName}(${signature}): ${message}${suffix}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Error for aggregate function misuse (e.g., SUM(*)).
|
|
208
|
+
*
|
|
209
|
+
* @param {string} funcName - The aggregate function
|
|
210
|
+
* @param {string} issue - What's wrong (e.g., "(*) is not supported")
|
|
211
|
+
* @returns {Error}
|
|
212
|
+
*/
|
|
213
|
+
export function aggregateError(funcName, issue) {
|
|
214
|
+
return new Error(`${funcName}${issue}. Only COUNT supports *. Use a column name for ${funcName}.`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Error for unsupported CAST type.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} toType - The unsupported target type
|
|
221
|
+
* @param {string} [fromType] - The source type (optional)
|
|
222
|
+
* @returns {Error}
|
|
223
|
+
*/
|
|
224
|
+
export function castError(toType, fromType) {
|
|
225
|
+
const message = fromType
|
|
226
|
+
? `Cannot CAST ${fromType} to ${toType}`
|
|
227
|
+
: `Unsupported CAST to type ${toType}`
|
|
228
|
+
|
|
229
|
+
return new Error(`${message}. Supported types: TEXT, VARCHAR, INTEGER, INT, BIGINT, FLOAT, REAL, DOUBLE, BOOLEAN`)
|
|
230
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { aggregateError, unknownFunctionError } from '../errors.js'
|
|
1
2
|
import { evaluateExpr } from './expression.js'
|
|
2
3
|
import { defaultDerivedAlias, stringify } from './utils.js'
|
|
3
4
|
|
|
@@ -20,7 +21,7 @@ export async function evaluateAggregate({ col, rows, tables }) {
|
|
|
20
21
|
const seen = new Set()
|
|
21
22
|
for (const row of rows) {
|
|
22
23
|
const v = await evaluateExpr({ node: arg.expr, row, tables })
|
|
23
|
-
if (v
|
|
24
|
+
if (v != null) {
|
|
24
25
|
seen.add(v)
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -29,7 +30,7 @@ export async function evaluateAggregate({ col, rows, tables }) {
|
|
|
29
30
|
let count = 0
|
|
30
31
|
for (const row of rows) {
|
|
31
32
|
const v = await evaluateExpr({ node: arg.expr, row, tables })
|
|
32
|
-
if (v
|
|
33
|
+
if (v != null) {
|
|
33
34
|
count += 1
|
|
34
35
|
}
|
|
35
36
|
}
|
|
@@ -38,7 +39,7 @@ export async function evaluateAggregate({ col, rows, tables }) {
|
|
|
38
39
|
|
|
39
40
|
if (func === 'SUM' || func === 'AVG' || func === 'MIN' || func === 'MAX') {
|
|
40
41
|
if (arg.kind === 'star') {
|
|
41
|
-
throw
|
|
42
|
+
throw aggregateError(func, '(*) is not supported, use a column name')
|
|
42
43
|
}
|
|
43
44
|
let sum = 0
|
|
44
45
|
let count = 0
|
|
@@ -72,7 +73,7 @@ export async function evaluateAggregate({ col, rows, tables }) {
|
|
|
72
73
|
|
|
73
74
|
if (func === 'JSON_ARRAYAGG') {
|
|
74
75
|
if (arg.kind === 'star') {
|
|
75
|
-
throw
|
|
76
|
+
throw aggregateError('JSON_ARRAYAGG', '(*) is not supported, use a column name or expression')
|
|
76
77
|
}
|
|
77
78
|
/** @type {SqlPrimitive[]} */
|
|
78
79
|
const values = []
|
|
@@ -95,7 +96,7 @@ export async function evaluateAggregate({ col, rows, tables }) {
|
|
|
95
96
|
return values
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
throw
|
|
99
|
+
throw unknownFunctionError(func, undefined, 'COUNT, SUM, AVG, MIN, MAX, JSON_ARRAYAGG')
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { SqlPrimitive, IntervalUnit } from '../types.js'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {SqlPrimitive} val
|
|
7
|
+
* @returns {Date | null}
|
|
8
|
+
*/
|
|
9
|
+
function toDate(val) {
|
|
10
|
+
if (val instanceof Date) return val
|
|
11
|
+
const dateOrTime = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?/
|
|
12
|
+
if (typeof val === 'string' && dateOrTime.test(val)) {
|
|
13
|
+
const date = new Date(val)
|
|
14
|
+
if (!isNaN(date.getTime())) {
|
|
15
|
+
return date
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Apply an interval to a date
|
|
23
|
+
* @param {SqlPrimitive} dateVal
|
|
24
|
+
* @param {number} value
|
|
25
|
+
* @param {IntervalUnit} unit
|
|
26
|
+
* @param {'+' | '-'} op
|
|
27
|
+
* @returns {string | null}
|
|
28
|
+
*/
|
|
29
|
+
export function applyIntervalToDate(dateVal, value, unit, op) {
|
|
30
|
+
const date = toDate(dateVal)
|
|
31
|
+
if (date == null) return null
|
|
32
|
+
|
|
33
|
+
const multiplier = op === '+' ? 1 : -1
|
|
34
|
+
const adjusted = value * multiplier
|
|
35
|
+
|
|
36
|
+
if (unit === 'SECOND') {
|
|
37
|
+
date.setUTCSeconds(date.getUTCSeconds() + adjusted)
|
|
38
|
+
} else if (unit === 'MINUTE') {
|
|
39
|
+
date.setUTCMinutes(date.getUTCMinutes() + adjusted)
|
|
40
|
+
} else if (unit === 'HOUR') {
|
|
41
|
+
date.setUTCHours(date.getUTCHours() + adjusted)
|
|
42
|
+
} else if (unit === 'DAY') {
|
|
43
|
+
date.setUTCDate(date.getUTCDate() + adjusted)
|
|
44
|
+
} else if (unit === 'MONTH') {
|
|
45
|
+
date.setUTCMonth(date.getUTCMonth() + adjusted)
|
|
46
|
+
} else if (unit === 'YEAR') {
|
|
47
|
+
date.setUTCFullYear(date.getUTCFullYear() + adjusted)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Return in same format as input
|
|
51
|
+
if (dateVal instanceof Date) return date.toISOString()
|
|
52
|
+
if (String(dateVal).includes('T')) {
|
|
53
|
+
return date.toISOString()
|
|
54
|
+
} else {
|
|
55
|
+
return date.toISOString().split('T')[0]
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/execute/execute.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { missingClauseError, tableNotFoundError, unsupportedOperationError } from '../errors.js'
|
|
1
2
|
import { generatorSource, memorySource } from '../backend/dataSource.js'
|
|
2
3
|
import { parseSql } from '../parse/parse.js'
|
|
3
4
|
import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
|
|
@@ -22,7 +23,10 @@ export async function* executeSql({ tables, query }) {
|
|
|
22
23
|
|
|
23
24
|
// Check for unsupported operations
|
|
24
25
|
if (!select.from) {
|
|
25
|
-
throw
|
|
26
|
+
throw missingClauseError({
|
|
27
|
+
missing: 'FROM clause',
|
|
28
|
+
context: 'SELECT statement',
|
|
29
|
+
})
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
// Normalize tables: convert arrays to AsyncDataSource
|
|
@@ -57,7 +61,7 @@ export async function* executeSelect(select, tables) {
|
|
|
57
61
|
fromTableName = select.from.alias ?? select.from.table
|
|
58
62
|
dataSource = tables[select.from.table]
|
|
59
63
|
if (dataSource === undefined) {
|
|
60
|
-
throw
|
|
64
|
+
throw tableNotFoundError(select.from.table)
|
|
61
65
|
}
|
|
62
66
|
} else {
|
|
63
67
|
// Nested subquery - recursively resolve
|
|
@@ -375,7 +379,10 @@ async function* evaluateBuffered(select, dataSource, tables, hasAggregate, useGr
|
|
|
375
379
|
|
|
376
380
|
const hasStar = select.columns.some(col => col.kind === 'star')
|
|
377
381
|
if (hasStar && hasAggregate) {
|
|
378
|
-
throw
|
|
382
|
+
throw unsupportedOperationError(
|
|
383
|
+
'SELECT * with aggregate functions is not supported',
|
|
384
|
+
'Replace * with specific column names when using aggregate functions.'
|
|
385
|
+
)
|
|
379
386
|
}
|
|
380
387
|
|
|
381
388
|
for (const group of groups) {
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
argCountError,
|
|
3
|
+
argValueError,
|
|
4
|
+
castError,
|
|
5
|
+
invalidContextError,
|
|
6
|
+
unknownFunctionError,
|
|
7
|
+
} from '../errors.js'
|
|
8
|
+
import { applyIntervalToDate } from './date.js'
|
|
1
9
|
import { executeSelect } from './execute.js'
|
|
2
10
|
import { applyBinaryOp, stringify } from './utils.js'
|
|
3
11
|
|
|
4
12
|
/**
|
|
5
|
-
* @import { ExprNode, AsyncRow, SqlPrimitive, AsyncDataSource } from '../types.js'
|
|
13
|
+
* @import { ExprNode, AsyncRow, SqlPrimitive, AsyncDataSource, IntervalUnit } from '../types.js'
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
16
|
/**
|
|
@@ -31,7 +39,7 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
31
39
|
return row[colName]()
|
|
32
40
|
}
|
|
33
41
|
}
|
|
34
|
-
return
|
|
42
|
+
return null
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
// Scalar subquery - returns a single value
|
|
@@ -66,6 +74,16 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
66
74
|
|
|
67
75
|
// Binary operators
|
|
68
76
|
if (node.type === 'binary') {
|
|
77
|
+
// Handle date +/- interval at AST level
|
|
78
|
+
if ((node.op === '+' || node.op === '-') && node.right.type === 'interval') {
|
|
79
|
+
const dateVal = await evaluateExpr({ node: node.left, row, tables })
|
|
80
|
+
return applyIntervalToDate(dateVal, node.right.value, node.right.unit, node.op)
|
|
81
|
+
}
|
|
82
|
+
if (node.op === '+' && node.left.type === 'interval') {
|
|
83
|
+
const dateVal = await evaluateExpr({ node: node.right, row, tables })
|
|
84
|
+
return applyIntervalToDate(dateVal, node.left.value, node.left.unit, '+')
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
const left = await evaluateExpr({ node: node.left, row, tables })
|
|
70
88
|
|
|
71
89
|
// Short-circuit evaluation for AND and OR
|
|
@@ -87,31 +105,35 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
87
105
|
const args = await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, tables })))
|
|
88
106
|
|
|
89
107
|
if (funcName === 'UPPER') {
|
|
90
|
-
if (args.length !== 1) throw
|
|
108
|
+
if (args.length !== 1) throw argCountError('UPPER', 1, args.length)
|
|
91
109
|
const val = args[0]
|
|
92
110
|
if (val == null) return null
|
|
93
111
|
return String(val).toUpperCase()
|
|
94
112
|
}
|
|
95
113
|
|
|
96
114
|
if (funcName === 'LOWER') {
|
|
97
|
-
if (args.length !== 1) throw
|
|
115
|
+
if (args.length !== 1) throw argCountError('LOWER', 1, args.length)
|
|
98
116
|
const val = args[0]
|
|
99
117
|
if (val == null) return null
|
|
100
118
|
return String(val).toLowerCase()
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
if (funcName === 'CONCAT') {
|
|
104
|
-
if (args.length < 1) throw
|
|
122
|
+
if (args.length < 1) throw argCountError('CONCAT', 'at least 1', args.length)
|
|
105
123
|
// SQL CONCAT returns NULL if any argument is NULL
|
|
106
124
|
if (args.some(a => a == null)) return null
|
|
107
125
|
if (args.some(a => typeof a === 'object')) {
|
|
108
|
-
throw
|
|
126
|
+
throw argValueError({
|
|
127
|
+
funcName: 'CONCAT',
|
|
128
|
+
message: 'does not support object arguments',
|
|
129
|
+
hint: 'Use CAST to convert objects to strings first.',
|
|
130
|
+
})
|
|
109
131
|
}
|
|
110
132
|
return args.map(a => String(a)).join('')
|
|
111
133
|
}
|
|
112
134
|
|
|
113
135
|
if (funcName === 'LENGTH') {
|
|
114
|
-
if (args.length !== 1) throw
|
|
136
|
+
if (args.length !== 1) throw argCountError('LENGTH', 1, args.length)
|
|
115
137
|
const val = args[0]
|
|
116
138
|
if (val == null) return null
|
|
117
139
|
return String(val).length
|
|
@@ -119,21 +141,28 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
119
141
|
|
|
120
142
|
if (funcName === 'SUBSTRING' || funcName === 'SUBSTR') {
|
|
121
143
|
if (args.length < 2 || args.length > 3) {
|
|
122
|
-
throw
|
|
144
|
+
throw argCountError(funcName, '2 or 3', args.length)
|
|
123
145
|
}
|
|
124
146
|
const str = args[0]
|
|
125
147
|
if (str == null) return null
|
|
126
148
|
const strVal = String(str)
|
|
127
149
|
const start = Number(args[1])
|
|
128
150
|
if (!Number.isInteger(start) || start < 1) {
|
|
129
|
-
throw
|
|
151
|
+
throw argValueError({
|
|
152
|
+
funcName,
|
|
153
|
+
message: `start position must be a positive integer, got ${args[1]}`,
|
|
154
|
+
hint: 'SQL uses 1-based indexing.',
|
|
155
|
+
})
|
|
130
156
|
}
|
|
131
157
|
// SQL uses 1-based indexing
|
|
132
158
|
const startIdx = start - 1
|
|
133
159
|
if (args.length === 3) {
|
|
134
160
|
const len = Number(args[2])
|
|
135
161
|
if (!Number.isInteger(len) || len < 0) {
|
|
136
|
-
throw
|
|
162
|
+
throw argValueError({
|
|
163
|
+
funcName,
|
|
164
|
+
message: `length must be a non-negative integer, got ${args[2]}`,
|
|
165
|
+
})
|
|
137
166
|
}
|
|
138
167
|
return strVal.substring(startIdx, startIdx + len)
|
|
139
168
|
}
|
|
@@ -141,14 +170,14 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
141
170
|
}
|
|
142
171
|
|
|
143
172
|
if (funcName === 'TRIM') {
|
|
144
|
-
if (args.length !== 1) throw
|
|
173
|
+
if (args.length !== 1) throw argCountError('TRIM', 1, args.length)
|
|
145
174
|
const val = args[0]
|
|
146
175
|
if (val == null) return null
|
|
147
176
|
return String(val).trim()
|
|
148
177
|
}
|
|
149
178
|
|
|
150
179
|
if (funcName === 'REPLACE') {
|
|
151
|
-
if (args.length !== 3) throw
|
|
180
|
+
if (args.length !== 3) throw argCountError('REPLACE', 3, args.length)
|
|
152
181
|
const str = args[0]
|
|
153
182
|
const searchStr = args[1]
|
|
154
183
|
const replaceStr = args[2]
|
|
@@ -158,13 +187,28 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
158
187
|
}
|
|
159
188
|
|
|
160
189
|
if (funcName === 'RANDOM' || funcName === 'RAND') {
|
|
161
|
-
if (args.length !== 0) throw
|
|
190
|
+
if (args.length !== 0) throw argCountError(funcName, 0, args.length)
|
|
162
191
|
return Math.random()
|
|
163
192
|
}
|
|
164
193
|
|
|
194
|
+
if (funcName === 'CURRENT_DATE') {
|
|
195
|
+
if (args.length !== 0) throw argCountError('CURRENT_DATE', 0, args.length)
|
|
196
|
+
return new Date().toISOString().split('T')[0]
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (funcName === 'CURRENT_TIME') {
|
|
200
|
+
if (args.length !== 0) throw argCountError('CURRENT_TIME', 0, args.length)
|
|
201
|
+
return new Date().toISOString().split('T')[1].replace('Z', '')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (funcName === 'CURRENT_TIMESTAMP') {
|
|
205
|
+
if (args.length !== 0) throw argCountError('CURRENT_TIMESTAMP', 0, args.length)
|
|
206
|
+
return new Date().toISOString()
|
|
207
|
+
}
|
|
208
|
+
|
|
165
209
|
if (funcName === 'JSON_OBJECT') {
|
|
166
210
|
if (args.length % 2 !== 0) {
|
|
167
|
-
throw
|
|
211
|
+
throw argCountError('JSON_OBJECT', 'even number', args.length)
|
|
168
212
|
}
|
|
169
213
|
/** @type {Record<string, SqlPrimitive>} */
|
|
170
214
|
const result = {}
|
|
@@ -172,7 +216,11 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
172
216
|
const key = args[i]
|
|
173
217
|
const value = args[i + 1]
|
|
174
218
|
if (key == null) {
|
|
175
|
-
throw
|
|
219
|
+
throw argValueError({
|
|
220
|
+
funcName: 'JSON_OBJECT',
|
|
221
|
+
message: 'key cannot be null',
|
|
222
|
+
hint: 'All keys must be non-null values.',
|
|
223
|
+
})
|
|
176
224
|
}
|
|
177
225
|
result[String(key)] = value
|
|
178
226
|
}
|
|
@@ -180,7 +228,7 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
180
228
|
}
|
|
181
229
|
|
|
182
230
|
if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY') {
|
|
183
|
-
if (args.length !== 2) throw
|
|
231
|
+
if (args.length !== 2) throw argCountError(funcName, 2, args.length)
|
|
184
232
|
let jsonArg = args[0]
|
|
185
233
|
const pathArg = args[1]
|
|
186
234
|
if (jsonArg == null || pathArg == null) return null
|
|
@@ -190,11 +238,18 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
190
238
|
try {
|
|
191
239
|
jsonArg = JSON.parse(jsonArg)
|
|
192
240
|
} catch {
|
|
193
|
-
throw
|
|
241
|
+
throw argValueError({
|
|
242
|
+
funcName,
|
|
243
|
+
message: 'invalid JSON string',
|
|
244
|
+
hint: 'First argument must be valid JSON.',
|
|
245
|
+
})
|
|
194
246
|
}
|
|
195
247
|
}
|
|
196
|
-
if (typeof jsonArg !== 'object') {
|
|
197
|
-
throw
|
|
248
|
+
if (typeof jsonArg !== 'object' || jsonArg instanceof Date) {
|
|
249
|
+
throw argValueError({
|
|
250
|
+
funcName,
|
|
251
|
+
message: `first argument must be JSON string or object, got ${typeof jsonArg}`,
|
|
252
|
+
})
|
|
198
253
|
}
|
|
199
254
|
|
|
200
255
|
// Parse path ("$.foo.bar[0].baz" or "foo.bar[0]")
|
|
@@ -223,7 +278,7 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
223
278
|
return current
|
|
224
279
|
}
|
|
225
280
|
|
|
226
|
-
throw
|
|
281
|
+
throw unknownFunctionError(funcName)
|
|
227
282
|
}
|
|
228
283
|
|
|
229
284
|
if (node.type === 'cast') {
|
|
@@ -235,7 +290,7 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
235
290
|
return String(val)
|
|
236
291
|
}
|
|
237
292
|
// Can only cast primitives to other primitive types
|
|
238
|
-
if (typeof val === 'object') throw
|
|
293
|
+
if (typeof val === 'object') throw castError(node.toType, 'object')
|
|
239
294
|
if (toType === 'INTEGER' || toType === 'INT') {
|
|
240
295
|
const num = Number(val)
|
|
241
296
|
if (isNaN(num)) return null
|
|
@@ -252,7 +307,7 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
252
307
|
if (toType === 'BOOLEAN' || toType === 'BOOL') {
|
|
253
308
|
return Boolean(val)
|
|
254
309
|
}
|
|
255
|
-
throw
|
|
310
|
+
throw castError(node.toType)
|
|
256
311
|
}
|
|
257
312
|
|
|
258
313
|
// IN and NOT IN with value lists
|
|
@@ -317,5 +372,14 @@ export async function evaluateExpr({ node, row, tables }) {
|
|
|
317
372
|
return null
|
|
318
373
|
}
|
|
319
374
|
|
|
320
|
-
|
|
375
|
+
// INTERVAL expressions should only appear as part of binary +/- operations
|
|
376
|
+
// which are handled above. A standalone interval is an error.
|
|
377
|
+
if (node.type === 'interval') {
|
|
378
|
+
throw invalidContextError({
|
|
379
|
+
item: 'INTERVAL',
|
|
380
|
+
validContext: 'date arithmetic (+ or -)',
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
throw new Error(`Unknown expression node type: ${node.type}. This is an internal error - the query may contain unsupported syntax.`)
|
|
321
385
|
}
|
package/src/execute/having.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { unknownFunctionError } from '../errors.js'
|
|
1
2
|
import { isAggregateFunc } from '../validation.js'
|
|
2
3
|
import { evaluateExpr } from './expression.js'
|
|
3
4
|
import { applyBinaryOp } from './utils.js'
|
|
@@ -152,5 +153,5 @@ async function evaluateAggregateFunction(funcName, args, group, tables) {
|
|
|
152
153
|
return max
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
throw
|
|
156
|
+
throw unknownFunctionError(funcName, undefined, 'COUNT, SUM, AVG, MIN, MAX')
|
|
156
157
|
}
|
package/src/execute/join.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { missingClauseError, tableNotFoundError } from '../errors.js'
|
|
1
2
|
import { evaluateExpr } from './expression.js'
|
|
2
3
|
import { stringify } from './utils.js'
|
|
3
4
|
|
|
@@ -22,7 +23,7 @@ export async function executeJoins(leftSource, joins, leftTableName, tables) {
|
|
|
22
23
|
const join = joins[0]
|
|
23
24
|
const rightSource = tables[join.table]
|
|
24
25
|
if (rightSource === undefined) {
|
|
25
|
-
throw
|
|
26
|
+
throw tableNotFoundError(join.table)
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// Buffer right rows for hash index (required for hash join)
|
|
@@ -62,7 +63,7 @@ export async function executeJoins(leftSource, joins, leftTableName, tables) {
|
|
|
62
63
|
const join = joins[i]
|
|
63
64
|
const rightSource = tables[join.table]
|
|
64
65
|
if (rightSource === undefined) {
|
|
65
|
-
throw
|
|
66
|
+
throw tableNotFoundError(join.table)
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/** @type {AsyncRow[]} */
|
|
@@ -98,7 +99,7 @@ export async function executeJoins(leftSource, joins, leftTableName, tables) {
|
|
|
98
99
|
const lastJoin = joins[joins.length - 1]
|
|
99
100
|
const rightSource = tables[lastJoin.table]
|
|
100
101
|
if (rightSource === undefined) {
|
|
101
|
-
throw
|
|
102
|
+
throw tableNotFoundError(lastJoin.table)
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** @type {AsyncRow[]} */
|
|
@@ -234,7 +235,10 @@ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tab
|
|
|
234
235
|
const { joinType, on: onCondition } = join
|
|
235
236
|
|
|
236
237
|
if (!onCondition) {
|
|
237
|
-
throw
|
|
238
|
+
throw missingClauseError({
|
|
239
|
+
missing: 'ON condition',
|
|
240
|
+
context: 'JOIN',
|
|
241
|
+
})
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
const keys = extractJoinKeys(onCondition, leftTable, rightTable)
|
package/src/execute/utils.js
CHANGED
|
@@ -134,6 +134,9 @@ export function defaultDerivedAlias(expr) {
|
|
|
134
134
|
if (expr.type === 'function') {
|
|
135
135
|
return expr.name.toLowerCase() + '_' + expr.args.map(defaultDerivedAlias).join('_')
|
|
136
136
|
}
|
|
137
|
+
if (expr.type === 'interval') {
|
|
138
|
+
return `interval_${expr.value}_${expr.unit.toLowerCase()}`
|
|
139
|
+
}
|
|
137
140
|
return 'expr'
|
|
138
141
|
}
|
|
139
142
|
|
package/src/parse/comparison.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { syntaxError } from '../errors.js'
|
|
1
2
|
import { isBinaryOp } from '../validation.js'
|
|
2
3
|
import { parseAdditive, parseExpression, parseSubquery } from './expression.js'
|
|
3
4
|
import { consume, current, expect, match, peekToken } from './state.js'
|
|
@@ -110,7 +111,7 @@ export function parseComparison(state) {
|
|
|
110
111
|
// parseSubquery expects to consume the opening paren itself
|
|
111
112
|
const parenTok = current(state)
|
|
112
113
|
if (parenTok.type !== 'paren' || parenTok.value !== '(') {
|
|
113
|
-
throw
|
|
114
|
+
throw syntaxError({ expected: '(', received: `"${parenTok.value}"`, position: parenTok.position, after: 'IN' })
|
|
114
115
|
}
|
|
115
116
|
const peekTok = peekToken(state, 1)
|
|
116
117
|
if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
|
|
@@ -155,7 +156,7 @@ export function parseComparison(state) {
|
|
|
155
156
|
// parseSubquery expects to consume the opening paren itself
|
|
156
157
|
const parenTok = current(state)
|
|
157
158
|
if (parenTok.type !== 'paren' || parenTok.value !== '(') {
|
|
158
|
-
throw
|
|
159
|
+
throw syntaxError({ expected: '(', received: `"${parenTok.value}"`, position: parenTok.position, after: 'IN' })
|
|
159
160
|
}
|
|
160
161
|
const peekTok = peekToken(state, 1)
|
|
161
162
|
if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
|
package/src/parse/expression.js
CHANGED
|
@@ -1,12 +1,66 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
invalidLiteralError,
|
|
3
|
+
missingClauseError,
|
|
4
|
+
syntaxError,
|
|
5
|
+
unknownFunctionError,
|
|
6
|
+
} from '../errors.js'
|
|
7
|
+
import { isAggregateFunc, isIntervalUnit, isStringFunc } from '../validation.js'
|
|
2
8
|
import { parseComparison } from './comparison.js'
|
|
3
9
|
import { parseSelectInternal } from './parse.js'
|
|
4
10
|
import { consume, current, expect, expectIdentifier, match, peekToken } from './state.js'
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
|
-
* @import { ExprNode, ParserState, SelectStatement, WhenClause } from '../types.js'
|
|
13
|
+
* @import { ExprNode, IntervalNode, ParserState, SelectStatement, WhenClause } from '../types.js'
|
|
8
14
|
*/
|
|
9
15
|
|
|
16
|
+
/**
|
|
17
|
+
* @param {ParserState} state
|
|
18
|
+
* @returns {IntervalNode}
|
|
19
|
+
*/
|
|
20
|
+
function parseInterval(state) {
|
|
21
|
+
consume(state) // INTERVAL
|
|
22
|
+
|
|
23
|
+
// Handle optional negative sign
|
|
24
|
+
let sign = 1
|
|
25
|
+
const signTok = current(state)
|
|
26
|
+
if (signTok.type === 'operator' && signTok.value === '-') {
|
|
27
|
+
consume(state)
|
|
28
|
+
sign = -1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Get value (number or quoted string)
|
|
32
|
+
const valueTok = current(state)
|
|
33
|
+
/** @type {number} */
|
|
34
|
+
let value
|
|
35
|
+
if (valueTok.type === 'number') {
|
|
36
|
+
consume(state)
|
|
37
|
+
value = sign * Number(valueTok.numericValue)
|
|
38
|
+
} else if (valueTok.type === 'string') {
|
|
39
|
+
consume(state)
|
|
40
|
+
const parsed = parseFloat(valueTok.value)
|
|
41
|
+
if (isNaN(parsed)) {
|
|
42
|
+
throw invalidLiteralError({ type: 'interval value', value: valueTok.value, position: valueTok.position })
|
|
43
|
+
}
|
|
44
|
+
value = sign * parsed
|
|
45
|
+
} else {
|
|
46
|
+
throw syntaxError({ expected: 'interval value (number)', received: `"${valueTok.value}"`, position: valueTok.position })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Get unit keyword
|
|
50
|
+
const unitTok = current(state)
|
|
51
|
+
if (unitTok.type !== 'keyword' || !isIntervalUnit(unitTok.value)) {
|
|
52
|
+
throw invalidLiteralError({
|
|
53
|
+
type: 'interval unit',
|
|
54
|
+
value: unitTok.value,
|
|
55
|
+
position: unitTok.position,
|
|
56
|
+
validValues: 'DAY, MONTH, YEAR, HOUR, MINUTE, SECOND',
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
consume(state)
|
|
60
|
+
|
|
61
|
+
return { type: 'interval', value, unit: unitTok.value }
|
|
62
|
+
}
|
|
63
|
+
|
|
10
64
|
/**
|
|
11
65
|
* @param {ParserState} state
|
|
12
66
|
* @returns {ExprNode}
|
|
@@ -64,7 +118,7 @@ export function parsePrimary(state) {
|
|
|
64
118
|
|
|
65
119
|
// validate function names
|
|
66
120
|
if (!isStringFunc(funcName) && !isAggregateFunc(funcName)) {
|
|
67
|
-
throw
|
|
121
|
+
throw unknownFunctionError(funcName, tok.position)
|
|
68
122
|
}
|
|
69
123
|
|
|
70
124
|
consume(state) // function name
|
|
@@ -99,6 +153,17 @@ export function parsePrimary(state) {
|
|
|
99
153
|
}
|
|
100
154
|
}
|
|
101
155
|
|
|
156
|
+
// Niladic datetime functions (no parentheses required per ANSI SQL)
|
|
157
|
+
const niladicFuncs = ['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP']
|
|
158
|
+
if (niladicFuncs.includes(tok.value)) {
|
|
159
|
+
consume(state)
|
|
160
|
+
return {
|
|
161
|
+
type: 'function',
|
|
162
|
+
name: tok.value,
|
|
163
|
+
args: [],
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
102
167
|
consume(state)
|
|
103
168
|
let name = tok.value
|
|
104
169
|
|
|
@@ -175,7 +240,10 @@ export function parsePrimary(state) {
|
|
|
175
240
|
}
|
|
176
241
|
|
|
177
242
|
if (whenClauses.length === 0) {
|
|
178
|
-
throw
|
|
243
|
+
throw missingClauseError({
|
|
244
|
+
missing: 'at least one WHEN clause',
|
|
245
|
+
context: 'CASE expression',
|
|
246
|
+
})
|
|
179
247
|
}
|
|
180
248
|
|
|
181
249
|
// Parse optional ELSE clause
|
|
@@ -194,6 +262,9 @@ export function parsePrimary(state) {
|
|
|
194
262
|
elseResult,
|
|
195
263
|
}
|
|
196
264
|
}
|
|
265
|
+
if (tok.value === 'INTERVAL') {
|
|
266
|
+
return parseInterval(state)
|
|
267
|
+
}
|
|
197
268
|
}
|
|
198
269
|
|
|
199
270
|
if (tok.type === 'operator' && tok.value === '-') {
|
|
@@ -207,7 +278,7 @@ export function parsePrimary(state) {
|
|
|
207
278
|
}
|
|
208
279
|
|
|
209
280
|
const found = tok.type === 'eof' ? 'end of query' : `"${tok.originalValue ?? tok.value}"`
|
|
210
|
-
throw
|
|
281
|
+
throw syntaxError({ expected: 'expression', received: found, position: tok.position })
|
|
211
282
|
}
|
|
212
283
|
|
|
213
284
|
/**
|
package/src/parse/parse.js
CHANGED
|
@@ -63,6 +63,11 @@ function parseSelectList(state) {
|
|
|
63
63
|
return cols
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Keywords that can start a valid expression in SELECT
|
|
67
|
+
const EXPRESSION_START_KEYWORDS = new Set([
|
|
68
|
+
'CASE', 'TRUE', 'FALSE', 'NULL', 'EXISTS', 'NOT', 'INTERVAL',
|
|
69
|
+
])
|
|
70
|
+
|
|
66
71
|
/**
|
|
67
72
|
* @param {ParserState} state
|
|
68
73
|
* @returns {SelectColumn}
|
|
@@ -70,7 +75,7 @@ function parseSelectList(state) {
|
|
|
70
75
|
function parseSelectItem(state) {
|
|
71
76
|
const tok = current(state)
|
|
72
77
|
|
|
73
|
-
if (tok.type === 'keyword' && tok.value
|
|
78
|
+
if (tok.type === 'keyword' && !EXPRESSION_START_KEYWORDS.has(tok.value) || tok.type === 'eof') {
|
|
74
79
|
throw parseError(state, 'column name or expression')
|
|
75
80
|
}
|
|
76
81
|
|
package/src/parse/state.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { syntaxError } from '../errors.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @import { ParserState, Token, TokenType } from '../types.js'
|
|
3
5
|
*/
|
|
@@ -86,7 +88,7 @@ export function expectIdentifier(state) {
|
|
|
86
88
|
export function parseError(state, expected) {
|
|
87
89
|
const tok = current(state)
|
|
88
90
|
const prevToken = state.tokens[state.pos - 1]
|
|
89
|
-
const after = prevToken ?
|
|
90
|
-
const
|
|
91
|
-
return
|
|
91
|
+
const after = prevToken ? prevToken.originalValue ?? prevToken.value : undefined
|
|
92
|
+
const received = tok.type === 'eof' ? 'end of query' : `"${tok.originalValue ?? tok.value}"`
|
|
93
|
+
return syntaxError({ expected, received, position: tok.position, after })
|
|
92
94
|
}
|
package/src/parse/tokenize.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
invalidLiteralError,
|
|
3
|
+
unexpectedCharError,
|
|
4
|
+
unterminatedError,
|
|
5
|
+
} from '../errors.js'
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* @import { Token } from '../types.d.ts'
|
|
3
9
|
*/
|
|
@@ -41,6 +47,13 @@ const KEYWORDS = new Set([
|
|
|
41
47
|
'FULL',
|
|
42
48
|
'OUTER',
|
|
43
49
|
'ON',
|
|
50
|
+
'INTERVAL',
|
|
51
|
+
'DAY',
|
|
52
|
+
'MONTH',
|
|
53
|
+
'YEAR',
|
|
54
|
+
'HOUR',
|
|
55
|
+
'MINUTE',
|
|
56
|
+
'SECOND',
|
|
44
57
|
])
|
|
45
58
|
|
|
46
59
|
/**
|
|
@@ -71,6 +84,61 @@ export function tokenize(sql) {
|
|
|
71
84
|
return ch
|
|
72
85
|
}
|
|
73
86
|
|
|
87
|
+
/**
|
|
88
|
+
* @param {number} startPos
|
|
89
|
+
* @param {string} prefix
|
|
90
|
+
* @returns {Token}
|
|
91
|
+
*/
|
|
92
|
+
function parseNumber(startPos, prefix = '') {
|
|
93
|
+
let text = prefix
|
|
94
|
+
while (isDigit(peek())) {
|
|
95
|
+
text += nextChar()
|
|
96
|
+
}
|
|
97
|
+
if (peek() === '.') {
|
|
98
|
+
text += nextChar()
|
|
99
|
+
while (isDigit(peek())) {
|
|
100
|
+
text += nextChar()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// exponent
|
|
104
|
+
if (peek() === 'e' || peek() === 'E') {
|
|
105
|
+
text += nextChar()
|
|
106
|
+
if (peek() === '+' || peek() === '-') {
|
|
107
|
+
text += nextChar()
|
|
108
|
+
}
|
|
109
|
+
while (isDigit(peek())) {
|
|
110
|
+
text += nextChar()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// bigint suffix
|
|
114
|
+
if (peek() === 'n') {
|
|
115
|
+
text += nextChar()
|
|
116
|
+
try {
|
|
117
|
+
return {
|
|
118
|
+
type: 'number',
|
|
119
|
+
value: text,
|
|
120
|
+
position: startPos,
|
|
121
|
+
numericValue: BigInt(text.slice(0, -1)),
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
throw invalidLiteralError({ type: 'bigint', value: text.slice(0, -1), position: startPos })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (isAlpha(peek())) {
|
|
128
|
+
throw invalidLiteralError({ type: 'number', value: text + peek(), position: startPos })
|
|
129
|
+
}
|
|
130
|
+
const num = parseFloat(text)
|
|
131
|
+
if (isNaN(num)) {
|
|
132
|
+
throw invalidLiteralError({ type: 'number', value: text, position: startPos })
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
type: 'number',
|
|
136
|
+
value: text,
|
|
137
|
+
position: startPos,
|
|
138
|
+
numericValue: num,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
74
142
|
while (i < length) {
|
|
75
143
|
const ch = peek()
|
|
76
144
|
|
|
@@ -102,41 +170,25 @@ export function tokenize(sql) {
|
|
|
102
170
|
|
|
103
171
|
const pos = i
|
|
104
172
|
|
|
173
|
+
// negative numbers (when not subtraction)
|
|
174
|
+
if (ch === '-' && i + 1 < length && isDigit(sql[i + 1])) {
|
|
175
|
+
const lastToken = tokens[tokens.length - 1]
|
|
176
|
+
const isValueBefore = lastToken && (
|
|
177
|
+
lastToken.type === 'identifier' ||
|
|
178
|
+
lastToken.type === 'number' ||
|
|
179
|
+
lastToken.type === 'string' ||
|
|
180
|
+
lastToken.type === 'paren' && lastToken.value === ')'
|
|
181
|
+
)
|
|
182
|
+
if (!isValueBefore) {
|
|
183
|
+
nextChar() // consume '-'
|
|
184
|
+
tokens.push(parseNumber(pos, '-'))
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
105
189
|
// numbers
|
|
106
190
|
if (isDigit(ch)) {
|
|
107
|
-
|
|
108
|
-
while (isDigit(peek())) {
|
|
109
|
-
text += nextChar()
|
|
110
|
-
}
|
|
111
|
-
if (peek() === '.') {
|
|
112
|
-
text += nextChar()
|
|
113
|
-
while (isDigit(peek())) {
|
|
114
|
-
text += nextChar()
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// exponent
|
|
118
|
-
if (peek() === 'e' || peek() === 'E') {
|
|
119
|
-
text += nextChar()
|
|
120
|
-
if (peek() === '+' || peek() === '-') {
|
|
121
|
-
text += nextChar()
|
|
122
|
-
}
|
|
123
|
-
while (isDigit(peek())) {
|
|
124
|
-
text += nextChar()
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (isAlpha(peek())) {
|
|
128
|
-
throw new Error(`Invalid number at position ${pos}: ${text}${peek()}`)
|
|
129
|
-
}
|
|
130
|
-
const num = parseFloat(text)
|
|
131
|
-
if (isNaN(num)) {
|
|
132
|
-
throw new Error(`Invalid number at position ${pos}: ${text}`)
|
|
133
|
-
}
|
|
134
|
-
tokens.push({
|
|
135
|
-
type: 'number',
|
|
136
|
-
value: text,
|
|
137
|
-
position: pos,
|
|
138
|
-
numericValue: num,
|
|
139
|
-
})
|
|
191
|
+
tokens.push(parseNumber(pos))
|
|
140
192
|
continue
|
|
141
193
|
}
|
|
142
194
|
|
|
@@ -170,7 +222,7 @@ export function tokenize(sql) {
|
|
|
170
222
|
let text = ''
|
|
171
223
|
while (i <= length) {
|
|
172
224
|
if (i === length) {
|
|
173
|
-
throw
|
|
225
|
+
throw unterminatedError('string', pos)
|
|
174
226
|
}
|
|
175
227
|
const c = nextChar()
|
|
176
228
|
if (c === quote) {
|
|
@@ -198,7 +250,7 @@ export function tokenize(sql) {
|
|
|
198
250
|
let text = ''
|
|
199
251
|
while (i <= length) {
|
|
200
252
|
if (i === length) {
|
|
201
|
-
throw
|
|
253
|
+
throw unterminatedError('identifier', pos)
|
|
202
254
|
}
|
|
203
255
|
const c = nextChar()
|
|
204
256
|
if (c === quote) {
|
|
@@ -288,9 +340,9 @@ export function tokenize(sql) {
|
|
|
288
340
|
}
|
|
289
341
|
|
|
290
342
|
if (tokens.length === 0) {
|
|
291
|
-
throw
|
|
343
|
+
throw unexpectedCharError(ch, pos, true)
|
|
292
344
|
}
|
|
293
|
-
throw
|
|
345
|
+
throw unexpectedCharError(ch, pos)
|
|
294
346
|
}
|
|
295
347
|
|
|
296
348
|
tokens.push({
|
package/src/types.d.ts
CHANGED
|
@@ -27,7 +27,15 @@ export interface ExecuteSqlOptions {
|
|
|
27
27
|
query: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export type SqlPrimitive =
|
|
30
|
+
export type SqlPrimitive =
|
|
31
|
+
| string
|
|
32
|
+
| number
|
|
33
|
+
| bigint
|
|
34
|
+
| boolean
|
|
35
|
+
| Date
|
|
36
|
+
| null
|
|
37
|
+
| SqlPrimitive[]
|
|
38
|
+
| Record<string, any>
|
|
31
39
|
|
|
32
40
|
export interface SelectStatement {
|
|
33
41
|
distinct: boolean
|
|
@@ -129,6 +137,14 @@ export interface SubqueryNode {
|
|
|
129
137
|
subquery: SelectStatement
|
|
130
138
|
}
|
|
131
139
|
|
|
140
|
+
export type IntervalUnit = 'DAY' | 'MONTH' | 'YEAR' | 'HOUR' | 'MINUTE' | 'SECOND'
|
|
141
|
+
|
|
142
|
+
export interface IntervalNode {
|
|
143
|
+
type: 'interval'
|
|
144
|
+
value: number
|
|
145
|
+
unit: IntervalUnit
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
export type ExprNode =
|
|
133
149
|
| LiteralNode
|
|
134
150
|
| IdentifierNode
|
|
@@ -141,6 +157,7 @@ export type ExprNode =
|
|
|
141
157
|
| ExistsNode
|
|
142
158
|
| CaseNode
|
|
143
159
|
| SubqueryNode
|
|
160
|
+
| IntervalNode
|
|
144
161
|
|
|
145
162
|
export interface StarColumn {
|
|
146
163
|
kind: 'star'
|
|
@@ -162,6 +179,9 @@ export type StringFunc =
|
|
|
162
179
|
| 'JSON_VALUE'
|
|
163
180
|
| 'JSON_QUERY'
|
|
164
181
|
| 'JSON_OBJECT'
|
|
182
|
+
| 'CURRENT_DATE'
|
|
183
|
+
| 'CURRENT_TIME'
|
|
184
|
+
| 'CURRENT_TIMESTAMP'
|
|
165
185
|
|
|
166
186
|
export interface AggregateArgStar {
|
|
167
187
|
kind: 'star'
|
|
@@ -227,6 +247,6 @@ export interface Token {
|
|
|
227
247
|
type: TokenType
|
|
228
248
|
value: string
|
|
229
249
|
position: number
|
|
230
|
-
numericValue?: number
|
|
250
|
+
numericValue?: number | bigint
|
|
231
251
|
originalValue?: string
|
|
232
252
|
}
|
package/src/validation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
/**
|
|
3
|
-
* @import {AggregateFunc, BinaryOp, ComparisonOp, StringFunc} from './types.js'
|
|
3
|
+
* @import {AggregateFunc, BinaryOp, ComparisonOp, IntervalUnit, StringFunc} from './types.js'
|
|
4
4
|
* @param {string} name
|
|
5
5
|
* @returns {name is AggregateFunc}
|
|
6
6
|
*/
|
|
@@ -8,6 +8,14 @@ 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 {name is IntervalUnit}
|
|
14
|
+
*/
|
|
15
|
+
export function isIntervalUnit(name) {
|
|
16
|
+
return ['DAY', 'MONTH', 'YEAR', 'HOUR', 'MINUTE', 'SECOND'].includes(name)
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* @param {string} name
|
|
13
21
|
* @returns {name is StringFunc}
|
|
@@ -27,6 +35,9 @@ export function isStringFunc(name) {
|
|
|
27
35
|
'JSON_VALUE',
|
|
28
36
|
'JSON_QUERY',
|
|
29
37
|
'JSON_OBJECT',
|
|
38
|
+
'CURRENT_DATE',
|
|
39
|
+
'CURRENT_TIME',
|
|
40
|
+
'CURRENT_TIMESTAMP',
|
|
30
41
|
].includes(name)
|
|
31
42
|
}
|
|
32
43
|
|