squirreling 0.4.6 → 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.
@@ -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
- import { applyBinaryOp } from './utils.js'
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 undefined
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
@@ -83,33 +101,39 @@ export async function evaluateExpr({ node, row, tables }) {
83
101
  // Function calls
84
102
  if (node.type === 'function') {
85
103
  const funcName = node.name.toUpperCase()
104
+ /** @type {SqlPrimitive[]} */
86
105
  const args = await Promise.all(node.args.map(arg => evaluateExpr({ node: arg, row, tables })))
87
106
 
88
107
  if (funcName === 'UPPER') {
89
- if (args.length !== 1) throw new Error('UPPER requires exactly 1 argument')
108
+ if (args.length !== 1) throw argCountError('UPPER', 1, args.length)
90
109
  const val = args[0]
91
110
  if (val == null) return null
92
111
  return String(val).toUpperCase()
93
112
  }
94
113
 
95
114
  if (funcName === 'LOWER') {
96
- if (args.length !== 1) throw new Error('LOWER requires exactly 1 argument')
115
+ if (args.length !== 1) throw argCountError('LOWER', 1, args.length)
97
116
  const val = args[0]
98
117
  if (val == null) return null
99
118
  return String(val).toLowerCase()
100
119
  }
101
120
 
102
121
  if (funcName === 'CONCAT') {
103
- if (args.length < 1) throw new Error('CONCAT requires at least 1 argument')
122
+ if (args.length < 1) throw argCountError('CONCAT', 'at least 1', args.length)
104
123
  // SQL CONCAT returns NULL if any argument is NULL
105
- for (let i = 0; i < args.length; i += 1) {
106
- if (args[i] == null) return null
124
+ if (args.some(a => a == null)) return null
125
+ if (args.some(a => typeof a === 'object')) {
126
+ throw argValueError({
127
+ funcName: 'CONCAT',
128
+ message: 'does not support object arguments',
129
+ hint: 'Use CAST to convert objects to strings first.',
130
+ })
107
131
  }
108
132
  return args.map(a => String(a)).join('')
109
133
  }
110
134
 
111
135
  if (funcName === 'LENGTH') {
112
- if (args.length !== 1) throw new Error('LENGTH requires exactly 1 argument')
136
+ if (args.length !== 1) throw argCountError('LENGTH', 1, args.length)
113
137
  const val = args[0]
114
138
  if (val == null) return null
115
139
  return String(val).length
@@ -117,21 +141,28 @@ export async function evaluateExpr({ node, row, tables }) {
117
141
 
118
142
  if (funcName === 'SUBSTRING' || funcName === 'SUBSTR') {
119
143
  if (args.length < 2 || args.length > 3) {
120
- throw new Error(`${funcName} requires 2 or 3 arguments`)
144
+ throw argCountError(funcName, '2 or 3', args.length)
121
145
  }
122
146
  const str = args[0]
123
147
  if (str == null) return null
124
148
  const strVal = String(str)
125
149
  const start = Number(args[1])
126
150
  if (!Number.isInteger(start) || start < 1) {
127
- throw new Error(`${funcName} start position must be a positive integer`)
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
+ })
128
156
  }
129
157
  // SQL uses 1-based indexing
130
158
  const startIdx = start - 1
131
159
  if (args.length === 3) {
132
160
  const len = Number(args[2])
133
161
  if (!Number.isInteger(len) || len < 0) {
134
- throw new Error(`${funcName} length must be a non-negative integer`)
162
+ throw argValueError({
163
+ funcName,
164
+ message: `length must be a non-negative integer, got ${args[2]}`,
165
+ })
135
166
  }
136
167
  return strVal.substring(startIdx, startIdx + len)
137
168
  }
@@ -139,14 +170,14 @@ export async function evaluateExpr({ node, row, tables }) {
139
170
  }
140
171
 
141
172
  if (funcName === 'TRIM') {
142
- if (args.length !== 1) throw new Error('TRIM requires exactly 1 argument')
173
+ if (args.length !== 1) throw argCountError('TRIM', 1, args.length)
143
174
  const val = args[0]
144
175
  if (val == null) return null
145
176
  return String(val).trim()
146
177
  }
147
178
 
148
179
  if (funcName === 'REPLACE') {
149
- if (args.length !== 3) throw new Error('REPLACE requires exactly 3 arguments')
180
+ if (args.length !== 3) throw argCountError('REPLACE', 3, args.length)
150
181
  const str = args[0]
151
182
  const searchStr = args[1]
152
183
  const replaceStr = args[2]
@@ -156,17 +187,110 @@ export async function evaluateExpr({ node, row, tables }) {
156
187
  }
157
188
 
158
189
  if (funcName === 'RANDOM' || funcName === 'RAND') {
159
- if (args.length !== 0) throw new Error(`${funcName} takes no arguments`)
190
+ if (args.length !== 0) throw argCountError(funcName, 0, args.length)
160
191
  return Math.random()
161
192
  }
162
193
 
163
- throw new Error('Unsupported function ' + funcName)
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
+
209
+ if (funcName === 'JSON_OBJECT') {
210
+ if (args.length % 2 !== 0) {
211
+ throw argCountError('JSON_OBJECT', 'even number', args.length)
212
+ }
213
+ /** @type {Record<string, SqlPrimitive>} */
214
+ const result = {}
215
+ for (let i = 0; i < args.length; i += 2) {
216
+ const key = args[i]
217
+ const value = args[i + 1]
218
+ if (key == null) {
219
+ throw argValueError({
220
+ funcName: 'JSON_OBJECT',
221
+ message: 'key cannot be null',
222
+ hint: 'All keys must be non-null values.',
223
+ })
224
+ }
225
+ result[String(key)] = value
226
+ }
227
+ return result
228
+ }
229
+
230
+ if (funcName === 'JSON_VALUE' || funcName === 'JSON_QUERY') {
231
+ if (args.length !== 2) throw argCountError(funcName, 2, args.length)
232
+ let jsonArg = args[0]
233
+ const pathArg = args[1]
234
+ if (jsonArg == null || pathArg == null) return null
235
+
236
+ // Parse JSON if string, otherwise use directly
237
+ if (typeof jsonArg === 'string') {
238
+ try {
239
+ jsonArg = JSON.parse(jsonArg)
240
+ } catch {
241
+ throw argValueError({
242
+ funcName,
243
+ message: 'invalid JSON string',
244
+ hint: 'First argument must be valid JSON.',
245
+ })
246
+ }
247
+ }
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
+ })
253
+ }
254
+
255
+ // Parse path ("$.foo.bar[0].baz" or "foo.bar[0]")
256
+ const path = String(pathArg)
257
+ const normalizedPath = path.startsWith('$') ? path.slice(1) : path
258
+
259
+ // Navigate the path
260
+ let current = jsonArg
261
+ const segments = normalizedPath.match(/\.?([^.[]+)|\[(\d+)\]/g) || []
262
+ for (const segment of segments) {
263
+ if (current == null) return null
264
+ if (segment.startsWith('[')) {
265
+ // Array index access
266
+ const index = parseInt(segment.slice(1, -1), 10)
267
+ if (!Array.isArray(current)) return null
268
+ current = current[index]
269
+ } else {
270
+ // Property access
271
+ const key = segment.startsWith('.') ? segment.slice(1) : segment
272
+ if (typeof current !== 'object' || Array.isArray(current)) return null
273
+ current = current[key]
274
+ }
275
+ }
276
+
277
+ if (current == null) return null
278
+ return current
279
+ }
280
+
281
+ throw unknownFunctionError(funcName)
164
282
  }
165
283
 
166
284
  if (node.type === 'cast') {
167
285
  const val = await evaluateExpr({ node: node.expr, row, tables })
168
286
  if (val == null) return null
169
287
  const toType = node.toType.toUpperCase()
288
+ if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
289
+ if (typeof val === 'object') return stringify(val)
290
+ return String(val)
291
+ }
292
+ // Can only cast primitives to other primitive types
293
+ if (typeof val === 'object') throw castError(node.toType, 'object')
170
294
  if (toType === 'INTEGER' || toType === 'INT') {
171
295
  const num = Number(val)
172
296
  if (isNaN(num)) return null
@@ -180,13 +304,10 @@ export async function evaluateExpr({ node, row, tables }) {
180
304
  if (isNaN(num)) return null
181
305
  return num
182
306
  }
183
- if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
184
- return String(val)
185
- }
186
307
  if (toType === 'BOOLEAN' || toType === 'BOOL') {
187
308
  return Boolean(val)
188
309
  }
189
- throw new Error('Unsupported CAST to type ' + node.toType)
310
+ throw castError(node.toType)
190
311
  }
191
312
 
192
313
  // IN and NOT IN with value lists
@@ -251,5 +372,14 @@ export async function evaluateExpr({ node, row, tables }) {
251
372
  return null
252
373
  }
253
374
 
254
- throw new Error('Unknown expression node type ' + node.type)
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.`)
255
385
  }
@@ -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'
@@ -41,7 +42,7 @@ export async function evaluateHavingExpr(expr, row, group, tables) {
41
42
  }
42
43
 
43
44
  const right = await evaluateHavingValue(expr.right, context, group, tables)
44
- return applyBinaryOp(expr.op, left, right)
45
+ return Boolean(applyBinaryOp(expr.op, left, right))
45
46
  }
46
47
 
47
48
  if (expr.type === 'unary') {
@@ -152,5 +153,5 @@ async function evaluateAggregateFunction(funcName, args, group, tables) {
152
153
  return max
153
154
  }
154
155
 
155
- throw new Error('Unsupported aggregate function: ' + funcName)
156
+ throw unknownFunctionError(funcName, undefined, 'COUNT, SUM, AVG, MIN, MAX')
156
157
  }
@@ -1,4 +1,6 @@
1
+ import { missingClauseError, tableNotFoundError } from '../errors.js'
1
2
  import { evaluateExpr } from './expression.js'
3
+ import { stringify } from './utils.js'
2
4
 
3
5
  /**
4
6
  * @import { AsyncRow, AsyncDataSource, JoinClause, ExprNode } from '../types.js'
@@ -21,7 +23,7 @@ export async function executeJoins(leftSource, joins, leftTableName, tables) {
21
23
  const join = joins[0]
22
24
  const rightSource = tables[join.table]
23
25
  if (rightSource === undefined) {
24
- throw new Error(`Table "${join.table}" not found`)
26
+ throw tableNotFoundError(join.table)
25
27
  }
26
28
 
27
29
  // Buffer right rows for hash index (required for hash join)
@@ -61,7 +63,7 @@ export async function executeJoins(leftSource, joins, leftTableName, tables) {
61
63
  const join = joins[i]
62
64
  const rightSource = tables[join.table]
63
65
  if (rightSource === undefined) {
64
- throw new Error(`Table "${join.table}" not found`)
66
+ throw tableNotFoundError(join.table)
65
67
  }
66
68
 
67
69
  /** @type {AsyncRow[]} */
@@ -97,7 +99,7 @@ export async function executeJoins(leftSource, joins, leftTableName, tables) {
97
99
  const lastJoin = joins[joins.length - 1]
98
100
  const rightSource = tables[lastJoin.table]
99
101
  if (rightSource === undefined) {
100
- throw new Error(`Table "${lastJoin.table}" not found`)
102
+ throw tableNotFoundError(lastJoin.table)
101
103
  }
102
104
 
103
105
  /** @type {AsyncRow[]} */
@@ -233,7 +235,10 @@ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tab
233
235
  const { joinType, on: onCondition } = join
234
236
 
235
237
  if (!onCondition) {
236
- throw new Error('JOIN requires ON condition')
238
+ throw missingClauseError({
239
+ missing: 'ON condition',
240
+ context: 'JOIN',
241
+ })
237
242
  }
238
243
 
239
244
  const keys = extractJoinKeys(onCondition, leftTable, rightTable)
@@ -258,8 +263,7 @@ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tab
258
263
  for (const rightRow of rightRows) {
259
264
  const keyValue = await evaluateExpr({ node: keys.rightKey, row: rightRow, tables })
260
265
  if (keyValue == null) continue // NULL keys never match
261
- const keyStr = JSON.stringify(keyValue)
262
-
266
+ const keyStr = stringify(keyValue)
263
267
  let bucket = hashMap.get(keyStr)
264
268
  if (!bucket) {
265
269
  bucket = []
@@ -283,7 +287,7 @@ async function* hashJoin({ leftRows, rightRows, join, leftTable, rightTable, tab
283
287
  }
284
288
 
285
289
  const keyValue = await evaluateExpr({ node: keys.leftKey, row: leftRow, tables })
286
- const keyStr = JSON.stringify(keyValue)
290
+ const keyStr = stringify(keyValue)
287
291
 
288
292
  const matchingRightRows = hashMap.get(keyStr)
289
293
 
@@ -3,14 +3,27 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Compares two values with the given operator, handling nulls according to SQL semantics
6
+ * Applies a binary operator to two values, handling nulls according to SQL semantics
7
7
  *
8
8
  * @param {BinaryOp} op
9
9
  * @param {SqlPrimitive} a
10
10
  * @param {SqlPrimitive} b
11
- * @returns {boolean}
11
+ * @returns {SqlPrimitive}
12
12
  */
13
13
  export function applyBinaryOp(op, a, b) {
14
+ // Arithmetic operators return null if either operand is null
15
+ if (op === '+' || op === '-' || op === '*' || op === '/' || op === '%') {
16
+ if (a == null || b == null) return null
17
+ const numA = Number(a)
18
+ const numB = Number(b)
19
+ if (op === '+') return numA + numB
20
+ if (op === '-') return numA - numB
21
+ if (op === '*') return numA * numB
22
+ if (op === '/') return numB === 0 ? null : numA / numB
23
+ if (op === '%') return numB === 0 ? null : numA % numB
24
+ }
25
+
26
+ // Comparison and logical operators
14
27
  if (a == null || b == null) {
15
28
  return false
16
29
  }
@@ -33,6 +46,8 @@ export function applyBinaryOp(op, a, b) {
33
46
  const regex = new RegExp(`^${regexPattern}$`, 'i')
34
47
  return regex.test(str)
35
48
  }
49
+
50
+ return null
36
51
  }
37
52
 
38
53
  /**
@@ -119,5 +134,22 @@ export function defaultDerivedAlias(expr) {
119
134
  if (expr.type === 'function') {
120
135
  return expr.name.toLowerCase() + '_' + expr.args.map(defaultDerivedAlias).join('_')
121
136
  }
137
+ if (expr.type === 'interval') {
138
+ return `interval_${expr.value}_${expr.unit.toLowerCase()}`
139
+ }
122
140
  return 'expr'
123
141
  }
142
+
143
+ /**
144
+ * @param {SqlPrimitive} value
145
+ * @returns {string}
146
+ */
147
+ export function stringify(value) {
148
+ if (value == null) return 'NULL'
149
+ return JSON.stringify(value, (_, val) => {
150
+ if (typeof val === 'bigint') {
151
+ return val <= Number.MAX_SAFE_INTEGER ? Number(val) : val.toString()
152
+ }
153
+ return val
154
+ })
155
+ }
@@ -1,5 +1,6 @@
1
+ import { syntaxError } from '../errors.js'
1
2
  import { isBinaryOp } from '../validation.js'
2
- import { parseExpression, parsePrimary, parseSubquery } from './expression.js'
3
+ import { parseAdditive, parseExpression, parseSubquery } from './expression.js'
3
4
  import { consume, current, expect, match, peekToken } from './state.js'
4
5
 
5
6
  /**
@@ -11,7 +12,7 @@ import { consume, current, expect, match, peekToken } from './state.js'
11
12
  * @returns {ExprNode}
12
13
  */
13
14
  export function parseComparison(state) {
14
- const left = parsePrimary(state)
15
+ const left = parseAdditive(state)
15
16
  const tok = current(state)
16
17
 
17
18
  // IS [NOT] NULL
@@ -41,7 +42,7 @@ export function parseComparison(state) {
41
42
  if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
42
43
  consume(state) // NOT
43
44
  consume(state) // LIKE
44
- const right = parsePrimary(state)
45
+ const right = parseAdditive(state)
45
46
  return {
46
47
  type: 'unary',
47
48
  op: 'NOT',
@@ -57,7 +58,7 @@ export function parseComparison(state) {
57
58
 
58
59
  if (tok.type === 'keyword' && tok.value === 'LIKE') {
59
60
  consume(state)
60
- const right = parsePrimary(state)
61
+ const right = parseAdditive(state)
61
62
  return {
62
63
  type: 'binary',
63
64
  op: 'LIKE',
@@ -72,9 +73,9 @@ export function parseComparison(state) {
72
73
  if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
73
74
  consume(state) // NOT
74
75
  consume(state) // BETWEEN
75
- const lower = parsePrimary(state)
76
+ const lower = parseAdditive(state)
76
77
  expect(state, 'keyword', 'AND')
77
- const upper = parsePrimary(state)
78
+ const upper = parseAdditive(state)
78
79
  // NOT BETWEEN -> expr < lower OR expr > upper
79
80
  return {
80
81
  type: 'binary',
@@ -87,9 +88,9 @@ export function parseComparison(state) {
87
88
 
88
89
  if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
89
90
  consume(state)
90
- const lower = parsePrimary(state)
91
+ const lower = parseAdditive(state)
91
92
  expect(state, 'keyword', 'AND')
92
- const upper = parsePrimary(state)
93
+ const upper = parseAdditive(state)
93
94
  // BETWEEN -> expr >= lower AND expr <= upper
94
95
  return {
95
96
  type: 'binary',
@@ -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 new Error('Expected ( after IN')
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 new Error('Expected ( after IN')
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') {
@@ -186,7 +187,7 @@ export function parseComparison(state) {
186
187
 
187
188
  if (tok.type === 'operator' && isBinaryOp(tok.value)) {
188
189
  consume(state)
189
- const right = parsePrimary(state)
190
+ const right = parseAdditive(state)
190
191
  return {
191
192
  type: 'binary',
192
193
  op: tok.value,
@@ -1,12 +1,66 @@
1
- import { isAggregateFunc, isStringFunc } from '../validation.js'
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 new Error(`Unknown function "${funcName}" at position ${tok.position}`)
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 new Error('CASE expression must have at least one WHEN clause')
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 new Error(`Expected expression but found ${found} at position ${tok.position}`)
281
+ throw syntaxError({ expected: 'expression', received: found, position: tok.position })
211
282
  }
212
283
 
213
284
  /**
@@ -272,6 +343,54 @@ function parseNot(state) {
272
343
  return parseComparison(state)
273
344
  }
274
345
 
346
+ /**
347
+ * @param {ParserState} state
348
+ * @returns {ExprNode}
349
+ */
350
+ export function parseAdditive(state) {
351
+ let node = parseMultiplicative(state)
352
+ while (true) {
353
+ const tok = current(state)
354
+ if (tok.type === 'operator' && (tok.value === '+' || tok.value === '-')) {
355
+ consume(state)
356
+ const right = parseMultiplicative(state)
357
+ node = {
358
+ type: 'binary',
359
+ op: tok.value,
360
+ left: node,
361
+ right,
362
+ }
363
+ } else {
364
+ break
365
+ }
366
+ }
367
+ return node
368
+ }
369
+
370
+ /**
371
+ * @param {ParserState} state
372
+ * @returns {ExprNode}
373
+ */
374
+ function parseMultiplicative(state) {
375
+ let node = parsePrimary(state)
376
+ while (true) {
377
+ const tok = current(state)
378
+ if (tok.type === 'operator' && (tok.value === '*' || tok.value === '/' || tok.value === '%')) {
379
+ consume(state)
380
+ const right = parsePrimary(state)
381
+ node = {
382
+ type: 'binary',
383
+ op: tok.value,
384
+ left: node,
385
+ right,
386
+ }
387
+ } else {
388
+ break
389
+ }
390
+ }
391
+ return node
392
+ }
393
+
275
394
  /**
276
395
  * Creates an ExprCursor adapter for the ParserState.
277
396
  *