squirreling 0.4.2 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -16,6 +16,16 @@ export async function evaluateAggregate({ col, rows, tables }) {
16
16
 
17
17
  if (func === 'COUNT') {
18
18
  if (arg.kind === 'star') return rows.length
19
+ if (arg.quantifier === 'distinct') {
20
+ const seen = new Set()
21
+ for (const row of rows) {
22
+ const v = await evaluateExpr({ node: arg.expr, row, tables })
23
+ if (v !== null && v !== undefined) {
24
+ seen.add(v)
25
+ }
26
+ }
27
+ return seen.size
28
+ }
19
29
  let count = 0
20
30
  for (const row of rows) {
21
31
  const v = await evaluateExpr({ node: arg.expr, row, tables })
@@ -210,7 +210,7 @@ export async function evaluateExpr({ node, row, tables }) {
210
210
  if (isNaN(num)) return null
211
211
  return num
212
212
  }
213
- if (toType === 'TEXT' || toType === 'STRING') {
213
+ if (toType === 'TEXT' || toType === 'STRING' || toType === 'VARCHAR') {
214
214
  return String(val)
215
215
  }
216
216
  if (toType === 'BOOLEAN' || toType === 'BOOL') {
@@ -0,0 +1,198 @@
1
+ import { isBinaryOp } from '../validation.js'
2
+ import { parseExpression, parsePrimary } from './expression.js'
3
+
4
+ /**
5
+ * @import { ExprCursor, ExprNode } from '../types.js'
6
+ */
7
+
8
+ /**
9
+ * @param {ExprCursor} c
10
+ * @returns {ExprNode}
11
+ */
12
+ export function parseComparison(c) {
13
+ const left = parsePrimary(c)
14
+ const tok = c.current()
15
+
16
+ // IS [NOT] NULL
17
+ if (tok.type === 'keyword' && tok.value === 'IS') {
18
+ c.consume()
19
+ const notToken = c.current()
20
+ if (notToken.type === 'keyword' && notToken.value === 'NOT') {
21
+ c.consume()
22
+ c.expect('keyword', 'NULL')
23
+ return {
24
+ type: 'unary',
25
+ op: 'IS NOT NULL',
26
+ argument: left,
27
+ }
28
+ }
29
+ c.expect('keyword', 'NULL')
30
+ return {
31
+ type: 'unary',
32
+ op: 'IS NULL',
33
+ argument: left,
34
+ }
35
+ }
36
+
37
+ // [NOT] LIKE
38
+ if (tok.type === 'keyword' && tok.value === 'NOT') {
39
+ const nextTok = c.peek(1)
40
+ if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
41
+ c.consume() // NOT
42
+ c.consume() // LIKE
43
+ const right = parsePrimary(c)
44
+ return {
45
+ type: 'unary',
46
+ op: 'NOT',
47
+ argument: {
48
+ type: 'binary',
49
+ op: 'LIKE',
50
+ left,
51
+ right,
52
+ },
53
+ }
54
+ }
55
+ }
56
+
57
+ if (tok.type === 'keyword' && tok.value === 'LIKE') {
58
+ c.consume()
59
+ const right = parsePrimary(c)
60
+ return {
61
+ type: 'binary',
62
+ op: 'LIKE',
63
+ left,
64
+ right,
65
+ }
66
+ }
67
+
68
+ // [NOT] BETWEEN - convert to range comparison
69
+ if (tok.type === 'keyword' && tok.value === 'NOT') {
70
+ const nextTok = c.peek(1)
71
+ if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
72
+ c.consume() // NOT
73
+ c.consume() // BETWEEN
74
+ const lower = parsePrimary(c)
75
+ c.expect('keyword', 'AND')
76
+ const upper = parsePrimary(c)
77
+ // NOT BETWEEN -> expr < lower OR expr > upper
78
+ return {
79
+ type: 'binary',
80
+ op: 'OR',
81
+ left: { type: 'binary', op: '<', left, right: lower },
82
+ right: { type: 'binary', op: '>', left, right: upper },
83
+ }
84
+ }
85
+ }
86
+
87
+ if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
88
+ c.consume()
89
+ const lower = parsePrimary(c)
90
+ c.expect('keyword', 'AND')
91
+ const upper = parsePrimary(c)
92
+ // BETWEEN -> expr >= lower AND expr <= upper
93
+ return {
94
+ type: 'binary',
95
+ op: 'AND',
96
+ left: { type: 'binary', op: '>=', left, right: lower },
97
+ right: { type: 'binary', op: '<=', left, right: upper },
98
+ }
99
+ }
100
+
101
+ // [NOT] IN
102
+ if (tok.type === 'keyword' && tok.value === 'NOT') {
103
+ const nextTok = c.peek(1)
104
+ if (nextTok.type === 'keyword' && nextTok.value === 'IN') {
105
+ c.consume() // NOT
106
+ c.consume() // IN
107
+
108
+ // Check if it's a subquery or a list of values by peeking ahead
109
+ // parseSubquery expects to consume the opening paren itself
110
+ const parenTok = c.current()
111
+ if (parenTok.type !== 'paren' || parenTok.value !== '(') {
112
+ throw new Error('Expected ( after IN')
113
+ }
114
+ const peekTok = c.peek(1)
115
+ if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
116
+ // Subquery - let parseSubquery handle the parens
117
+ const subquery = c.parseSubquery()
118
+ return {
119
+ type: 'unary',
120
+ op: 'NOT',
121
+ argument: {
122
+ type: 'in',
123
+ expr: left,
124
+ subquery,
125
+ },
126
+ }
127
+ } else {
128
+ // Parse list of values - we handle the parens
129
+ c.consume() // '('
130
+ /** @type {ExprNode[]} */
131
+ const values = []
132
+ while (true) {
133
+ values.push(parseExpression(c))
134
+ if (!c.match('comma')) break
135
+ }
136
+ c.expect('paren', ')')
137
+ return {
138
+ type: 'unary',
139
+ op: 'NOT',
140
+ argument: {
141
+ type: 'in valuelist',
142
+ expr: left,
143
+ values,
144
+ },
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ if (tok.type === 'keyword' && tok.value === 'IN') {
151
+ c.consume() // IN
152
+
153
+ // Check if it's a subquery or a list of values by peeking ahead
154
+ // parseSubquery expects to consume the opening paren itself
155
+ const parenTok = c.current()
156
+ if (parenTok.type !== 'paren' || parenTok.value !== '(') {
157
+ throw new Error('Expected ( after IN')
158
+ }
159
+ const peekTok = c.peek(1)
160
+ if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
161
+ // Subquery - let parseSubquery handle the parens
162
+ const subquery = c.parseSubquery()
163
+ return {
164
+ type: 'in',
165
+ expr: left,
166
+ subquery,
167
+ }
168
+ } else {
169
+ // Parse list of values - we handle the parens
170
+ c.consume() // '('
171
+ /** @type {ExprNode[]} */
172
+ const values = []
173
+ while (true) {
174
+ values.push(parseExpression(c))
175
+ if (!c.match('comma')) break
176
+ }
177
+ c.expect('paren', ')')
178
+ return {
179
+ type: 'in valuelist',
180
+ expr: left,
181
+ values,
182
+ }
183
+ }
184
+ }
185
+
186
+ if (tok.type === 'operator' && isBinaryOp(tok.value)) {
187
+ c.consume()
188
+ const right = parsePrimary(c)
189
+ return {
190
+ type: 'binary',
191
+ op: tok.value,
192
+ left,
193
+ right,
194
+ }
195
+ }
196
+
197
+ return left
198
+ }
@@ -1,7 +1,8 @@
1
1
  import { isAggregateFunc, isStringFunc } from '../validation.js'
2
+ import { parseComparison } from './comparison.js'
2
3
 
3
4
  /**
4
- * @import { BinaryOp, ExprCursor, ExprNode, WhenClause } from '../types.js'
5
+ * @import { ExprCursor, ExprNode, WhenClause } from '../types.js'
5
6
  */
6
7
 
7
8
  /**
@@ -16,7 +17,7 @@ export function parseExpression(c) {
16
17
  * @param {ExprCursor} c
17
18
  * @returns {ExprNode}
18
19
  */
19
- function parsePrimary(c) {
20
+ export function parsePrimary(c) {
20
21
  const tok = c.current()
21
22
 
22
23
  if (tok.type === 'paren' && tok.value === '(') {
@@ -253,9 +254,6 @@ function parseNot(c) {
253
254
  const nextTok = c.current()
254
255
  if (nextTok.type === 'keyword' && nextTok.value === 'EXISTS') {
255
256
  c.consume() // EXISTS
256
- if (!c.parseSubquery) {
257
- throw new Error('Subquery parsing not available in this context')
258
- }
259
257
  const subquery = c.parseSubquery()
260
258
  return {
261
259
  type: 'not exists',
@@ -271,217 +269,3 @@ function parseNot(c) {
271
269
  }
272
270
  return parseComparison(c)
273
271
  }
274
-
275
- /**
276
- * @param {ExprCursor} c
277
- * @returns {ExprNode}
278
- */
279
- function parseComparison(c) {
280
- const left = parsePrimary(c)
281
- const tok = c.current()
282
-
283
- // IS [NOT] NULL
284
- if (tok.type === 'keyword' && tok.value === 'IS') {
285
- c.consume()
286
- const notToken = c.current()
287
- if (notToken.type === 'keyword' && notToken.value === 'NOT') {
288
- c.consume()
289
- c.expect('keyword', 'NULL')
290
- return {
291
- type: 'unary',
292
- op: 'IS NOT NULL',
293
- argument: left,
294
- }
295
- }
296
- c.expect('keyword', 'NULL')
297
- return {
298
- type: 'unary',
299
- op: 'IS NULL',
300
- argument: left,
301
- }
302
- }
303
-
304
- // [NOT] LIKE
305
- if (tok.type === 'keyword' && tok.value === 'NOT') {
306
- const nextTok = c.peek(1)
307
- if (nextTok.type === 'keyword' && nextTok.value === 'LIKE') {
308
- c.consume() // NOT
309
- c.consume() // LIKE
310
- const right = parsePrimary(c)
311
- return {
312
- type: 'unary',
313
- op: 'NOT',
314
- argument: {
315
- type: 'binary',
316
- op: 'LIKE',
317
- left,
318
- right,
319
- },
320
- }
321
- }
322
- }
323
-
324
- if (tok.type === 'keyword' && tok.value === 'LIKE') {
325
- c.consume()
326
- const right = parsePrimary(c)
327
- return {
328
- type: 'binary',
329
- op: 'LIKE',
330
- left,
331
- right,
332
- }
333
- }
334
-
335
- // [NOT] BETWEEN - convert to range comparison
336
- if (tok.type === 'keyword' && tok.value === 'NOT') {
337
- const nextTok = c.peek(1)
338
- if (nextTok.type === 'keyword' && nextTok.value === 'BETWEEN') {
339
- c.consume() // NOT
340
- c.consume() // BETWEEN
341
- const lower = parsePrimary(c)
342
- c.expect('keyword', 'AND')
343
- const upper = parsePrimary(c)
344
- // NOT BETWEEN -> expr < lower OR expr > upper
345
- return {
346
- type: 'binary',
347
- op: 'OR',
348
- left: { type: 'binary', op: '<', left, right: lower },
349
- right: { type: 'binary', op: '>', left, right: upper },
350
- }
351
- }
352
- }
353
-
354
- if (tok.type === 'keyword' && tok.value === 'BETWEEN') {
355
- c.consume()
356
- const lower = parsePrimary(c)
357
- c.expect('keyword', 'AND')
358
- const upper = parsePrimary(c)
359
- // BETWEEN -> expr >= lower AND expr <= upper
360
- return {
361
- type: 'binary',
362
- op: 'AND',
363
- left: { type: 'binary', op: '>=', left, right: lower },
364
- right: { type: 'binary', op: '<=', left, right: upper },
365
- }
366
- }
367
-
368
- // [NOT] IN
369
- if (tok.type === 'keyword' && tok.value === 'NOT') {
370
- const nextTok = c.peek(1)
371
- if (nextTok.type === 'keyword' && nextTok.value === 'IN') {
372
- c.consume() // NOT
373
- c.consume() // IN
374
-
375
- // Check if it's a subquery or a list of values by peeking ahead
376
- // parseSubquery expects to consume the opening paren itself
377
- const parenTok = c.current()
378
- if (parenTok.type !== 'paren' || parenTok.value !== '(') {
379
- throw new Error('Expected ( after IN')
380
- }
381
- const peekTok = c.peek(1)
382
- if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
383
- // Subquery - let parseSubquery handle the parens
384
- if (!c.parseSubquery) {
385
- throw new Error('Subquery parsing not available in this context')
386
- }
387
- const subquery = c.parseSubquery()
388
- return {
389
- type: 'unary',
390
- op: 'NOT',
391
- argument: {
392
- type: 'in',
393
- expr: left,
394
- subquery,
395
- },
396
- }
397
- } else {
398
- // Parse list of values - we handle the parens
399
- c.consume() // '('
400
- /** @type {ExprNode[]} */
401
- const values = []
402
- while (true) {
403
- values.push(parseExpression(c))
404
- if (!c.match('comma')) break
405
- }
406
- c.expect('paren', ')')
407
- return {
408
- type: 'unary',
409
- op: 'NOT',
410
- argument: {
411
- type: 'in valuelist',
412
- expr: left,
413
- values,
414
- },
415
- }
416
- }
417
- }
418
- }
419
-
420
- if (tok.type === 'keyword' && tok.value === 'IN') {
421
- c.consume() // IN
422
-
423
- // Check if it's a subquery or a list of values by peeking ahead
424
- // parseSubquery expects to consume the opening paren itself
425
- const parenTok = c.current()
426
- if (parenTok.type !== 'paren' || parenTok.value !== '(') {
427
- throw new Error('Expected ( after IN')
428
- }
429
- const peekTok = c.peek(1)
430
- if (peekTok.type === 'keyword' && peekTok.value === 'SELECT') {
431
- // Subquery - let parseSubquery handle the parens
432
- if (!c.parseSubquery) {
433
- throw new Error('Subquery parsing not available in this context')
434
- }
435
- const subquery = c.parseSubquery()
436
- return {
437
- type: 'in',
438
- expr: left,
439
- subquery,
440
- }
441
- } else {
442
- // Parse list of values - we handle the parens
443
- c.consume() // '('
444
- /** @type {ExprNode[]} */
445
- const values = []
446
- while (true) {
447
- values.push(parseExpression(c))
448
- if (!c.match('comma')) break
449
- }
450
- c.expect('paren', ')')
451
- return {
452
- type: 'in valuelist',
453
- expr: left,
454
- values,
455
- }
456
- }
457
- }
458
-
459
- if (tok.type === 'operator' && isComparisonOperator(tok.value)) {
460
- c.consume()
461
- const right = parsePrimary(c)
462
- return {
463
- type: 'binary',
464
- op: tok.value,
465
- left,
466
- right,
467
- }
468
- }
469
-
470
- return left
471
- }
472
-
473
- /**
474
- * @param {string} op
475
- * @returns {op is BinaryOp}
476
- */
477
- function isComparisonOperator(op) {
478
- return (
479
- op === '=' ||
480
- op === '!=' ||
481
- op === '<>' ||
482
- op === '<' ||
483
- op === '>' ||
484
- op === '<=' ||
485
- op === '>='
486
- )
487
- }
@@ -229,31 +229,22 @@ function parseAggregateItem(state, func) {
229
229
  if (cur.type === 'operator' && cur.value === '*') {
230
230
  consume(state)
231
231
  arg = { kind: 'star' }
232
- } else if (cur.type === 'identifier' && cur.value === 'CAST') {
233
- // Handle CAST inside aggregate: SUM(CAST(x AS type))
234
- expectIdentifier(state) // consume CAST
235
- expect(state, 'paren', '(')
236
- const cursor = createExprCursor(state)
237
- const expr = parseExpression(cursor)
238
- expect(state, 'keyword', 'AS')
239
- const toType = expectIdentifier(state).value
240
- expect(state, 'paren', ')')
241
- arg = {
242
- kind: 'expression',
243
- expr: { type: 'cast', expr, toType },
244
- }
245
232
  } else {
246
- // column name
247
- let name = expectIdentifier(state).value
248
- // Handle qualified column names like orders.amount
249
- if (current(state).type === 'dot') {
250
- consume(state) // consume dot
251
- const qualifiedPart = expectIdentifier(state)
252
- name = `${name}.${qualifiedPart.value}`
233
+ /** @type {'all' | 'distinct'} */
234
+ let quantifier = 'all'
235
+ if (cur.type === 'keyword' && cur.value === 'ALL') {
236
+ consume(state) // consume ALL
237
+ } else if (cur.type === 'keyword' && cur.value === 'DISTINCT') {
238
+ consume(state)
239
+ quantifier = 'distinct'
253
240
  }
241
+
242
+ const cursor = createExprCursor(state)
243
+ const expr = parseExpression(cursor)
254
244
  arg = {
255
245
  kind: 'expression',
256
- expr: { type: 'identifier', name },
246
+ expr,
247
+ quantifier,
257
248
  }
258
249
  }
259
250
 
@@ -20,6 +20,7 @@ const KEYWORDS = new Set([
20
20
  'LIMIT',
21
21
  'OFFSET',
22
22
  'AS',
23
+ 'ALL',
23
24
  'DISTINCT',
24
25
  'TRUE',
25
26
  'FALSE',
package/src/types.d.ts CHANGED
@@ -154,6 +154,7 @@ export interface AggregateArgStar {
154
154
  export interface AggregateArgExpression {
155
155
  kind: 'expression'
156
156
  expr: ExprNode
157
+ quantifier: 'all' | 'distinct'
157
158
  }
158
159
 
159
160
  export type AggregateArg = AggregateArgStar | AggregateArgExpression
package/src/validation.js CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  /**
3
- * @import {AggregateFunc, StringFunc} from './types.js'
3
+ * @import {AggregateFunc, BinaryOp, StringFunc} from './types.js'
4
4
  * @param {string} name
5
5
  * @returns {name is AggregateFunc}
6
6
  */
@@ -15,3 +15,11 @@ export function isAggregateFunc(name) {
15
15
  export function isStringFunc(name) {
16
16
  return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM', 'REPLACE', 'RANDOM', 'RAND'].includes(name)
17
17
  }
18
+
19
+ /**
20
+ * @param {string} op
21
+ * @returns {op is BinaryOp}
22
+ */
23
+ export function isBinaryOp(op) {
24
+ return ['=', '!=', '<>', '<', '>', '<=', '>='].includes(op)
25
+ }