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.
@@ -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 !== 'CASE' || tok.type === 'eof') {
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
 
@@ -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 ? ` after "${prevToken.originalValue ?? prevToken.value}"` : ''
90
- const found = tok.type === 'eof' ? 'end of query' : `"${tok.originalValue ?? tok.value}"`
91
- return new Error(`Expected ${expected}${after} but found ${found} at position ${tok.position}`)
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
  }
@@ -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
- let text = ''
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 new Error(`Unterminated string literal starting at position ${pos}`)
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 new Error(`Unterminated identifier starting at position ${pos}`)
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 new Error(`Expected SELECT but found "${ch}" at position ${pos}`)
343
+ throw unexpectedCharError(ch, pos, true)
292
344
  }
293
- throw new Error(`Unexpected character "${ch}" at position ${pos}`)
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 = string | number | bigint | boolean | null
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
@@ -54,7 +62,9 @@ export interface FromSubquery {
54
62
  alias: string
55
63
  }
56
64
 
57
- export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp
65
+ export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
66
+
67
+ export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp | ArithmeticOp
58
68
 
59
69
  export type ComparisonOp = '=' | '!=' | '<>' | '<' | '>' | '<=' | '>='
60
70
 
@@ -127,6 +137,14 @@ export interface SubqueryNode {
127
137
  subquery: SelectStatement
128
138
  }
129
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
+
130
148
  export type ExprNode =
131
149
  | LiteralNode
132
150
  | IdentifierNode
@@ -139,6 +157,7 @@ export type ExprNode =
139
157
  | ExistsNode
140
158
  | CaseNode
141
159
  | SubqueryNode
160
+ | IntervalNode
142
161
 
143
162
  export interface StarColumn {
144
163
  kind: 'star'
@@ -146,9 +165,23 @@ export interface StarColumn {
146
165
  alias?: string
147
166
  }
148
167
 
149
- export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX'
150
-
151
- export type StringFunc = 'UPPER' | 'LOWER' | 'CONCAT' | 'LENGTH' | 'SUBSTRING' | 'SUBSTR' | 'TRIM' | 'REPLACE'
168
+ export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'JSON_ARRAYAGG'
169
+
170
+ export type StringFunc =
171
+ | 'UPPER'
172
+ | 'LOWER'
173
+ | 'CONCAT'
174
+ | 'LENGTH'
175
+ | 'SUBSTRING'
176
+ | 'SUBSTR'
177
+ | 'TRIM'
178
+ | 'REPLACE'
179
+ | 'JSON_VALUE'
180
+ | 'JSON_QUERY'
181
+ | 'JSON_OBJECT'
182
+ | 'CURRENT_DATE'
183
+ | 'CURRENT_TIME'
184
+ | 'CURRENT_TIMESTAMP'
152
185
 
153
186
  export interface AggregateArgStar {
154
187
  kind: 'star'
@@ -214,6 +247,6 @@ export interface Token {
214
247
  type: TokenType
215
248
  value: string
216
249
  position: number
217
- numericValue?: number
250
+ numericValue?: number | bigint
218
251
  originalValue?: string
219
252
  }
package/src/validation.js CHANGED
@@ -1,11 +1,19 @@
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
  */
7
7
  export function isAggregateFunc(name) {
8
- return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX'].includes(name)
8
+ return ['COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'JSON_ARRAYAGG'].includes(name)
9
+ }
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)
9
17
  }
10
18
 
11
19
  /**
@@ -13,7 +21,24 @@ export function isAggregateFunc(name) {
13
21
  * @returns {name is StringFunc}
14
22
  */
15
23
  export function isStringFunc(name) {
16
- return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE', 'RANDOM', 'RAND'].includes(name)
24
+ return [
25
+ 'UPPER',
26
+ 'LOWER',
27
+ 'CONCAT',
28
+ 'LENGTH',
29
+ 'SUBSTRING',
30
+ 'SUBSTR',
31
+ 'TRIM',
32
+ 'REPLACE',
33
+ 'RANDOM',
34
+ 'RAND',
35
+ 'JSON_VALUE',
36
+ 'JSON_QUERY',
37
+ 'JSON_OBJECT',
38
+ 'CURRENT_DATE',
39
+ 'CURRENT_TIME',
40
+ 'CURRENT_TIMESTAMP',
41
+ ].includes(name)
17
42
  }
18
43
 
19
44
  /**