squirreling 0.2.4 → 0.2.5

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.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Squirreling SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -1,13 +1,13 @@
1
- /**
2
- * @import { DataSource, ExecuteSqlOptions, FunctionColumn, FunctionNode, OrderByItem, RowSource, SelectStatement, SqlPrimitive } from '../types.js'
3
- */
4
-
5
1
  import { defaultAggregateAlias, evaluateAggregate } from './aggregates.js'
6
2
  import { evaluateExpr } from './expression.js'
7
3
  import { evaluateHavingExpr } from './having.js'
8
4
  import { parseSql } from '../parse/parse.js'
9
5
  import { createMemorySource, createRowAccessor } from '../backend/memory.js'
10
6
 
7
+ /**
8
+ * @import { DataSource, ExecuteSqlOptions, ExprNode, OrderByItem, RowSource, SelectStatement, SqlPrimitive } from '../types.js'
9
+ */
10
+
11
11
  /**
12
12
  * Executes a SQL SELECT query against a data source
13
13
  *
@@ -21,21 +21,31 @@ export function executeSql({ source, query }) {
21
21
  }
22
22
 
23
23
  /**
24
- * Generates a default alias name for a string function
24
+ * Generates a default alias for a derived column expression
25
25
  *
26
- * @param {FunctionColumn} col - the function column definition
27
- * @returns {string} the generated alias (e.g., "upper_name", "concat_a_b")
26
+ * @param {ExprNode} expr - the expression node
27
+ * @returns {string} the generated alias
28
28
  */
29
- function defaultFunctionAlias(col) {
30
- const base = col.func.toLowerCase()
31
- // Try to extract column names from identifier arguments
32
- const columnNames = col.args
33
- .filter(arg => arg.type === 'identifier')
34
- .map(arg => arg.name)
35
- if (columnNames.length > 0) {
36
- return base + '_' + columnNames.join('_')
29
+ function defaultDerivedAlias(expr) {
30
+ if (expr.type === 'identifier') {
31
+ return expr.name
37
32
  }
38
- return base
33
+ if (expr.type === 'function') {
34
+ const base = expr.name.toLowerCase()
35
+ // Try to extract column names from identifier arguments
36
+ const columnNames = expr.args
37
+ .filter(arg => arg.type === 'identifier')
38
+ .map(arg => arg.name)
39
+ if (columnNames.length > 0) {
40
+ return base + '_' + columnNames.join('_')
41
+ }
42
+ return base
43
+ }
44
+ if (expr.type === 'cast') return 'cast_expr'
45
+ if (expr.type === 'unary' && expr.argument.type === 'identifier') {
46
+ return expr.op === '-' ? 'neg_' + expr.argument.name : 'expr'
47
+ }
48
+ return 'expr'
39
49
  }
40
50
 
41
51
  /**
@@ -118,6 +128,24 @@ function applyOrderBy(rows, orderBy) {
118
128
  const dir = term.direction
119
129
  const av = evaluateExpr(term.expr, createRowAccessor(a))
120
130
  const bv = evaluateExpr(term.expr, createRowAccessor(b))
131
+
132
+ // Handle NULLS FIRST / NULLS LAST
133
+ const aIsNull = av == null
134
+ const bIsNull = bv == null
135
+
136
+ if (aIsNull || bIsNull) {
137
+ if (aIsNull && bIsNull) continue // both null, try next sort term
138
+
139
+ // Determine null ordering
140
+ const nullsFirst = term.nulls === 'LAST' ? false : true // default is NULLS FIRST
141
+
142
+ if (aIsNull) {
143
+ return nullsFirst ? -1 : 1
144
+ } else {
145
+ return nullsFirst ? 1 : -1
146
+ }
147
+ }
148
+
121
149
  const cmp = compareValues(av, bv)
122
150
  if (cmp !== 0) {
123
151
  return dir === 'DESC' ? -cmp : cmp
@@ -214,20 +242,9 @@ function evaluateSelectAst(select, dataSource) {
214
242
  continue
215
243
  }
216
244
 
217
- if (col.kind === 'column') {
218
- const name = col.column
219
- const alias = col.alias ?? name
220
- // Evaluate on first row of group (all rows have same value for GROUP BY columns)
221
- resultRow[alias] = group[0]?.getCell(name)
222
- continue
223
- }
224
-
225
- if (col.kind === 'function') {
226
- // Evaluate function on the first row of the group
227
- /** @type {FunctionNode} */
228
- const funcNode = { type: 'function', name: col.func, args: col.args }
229
- const alias = col.alias ?? defaultFunctionAlias(col)
230
- const value = group.length > 0 ? evaluateExpr(funcNode, group[0]) : undefined
245
+ if (col.kind === 'derived') {
246
+ const alias = col.alias ?? defaultDerivedAlias(col.expr)
247
+ const value = group.length > 0 ? evaluateExpr(col.expr, group[0]) : undefined
231
248
  resultRow[alias] = value
232
249
  continue
233
250
  }
@@ -238,13 +255,6 @@ function evaluateSelectAst(select, dataSource) {
238
255
  resultRow[alias] = value
239
256
  continue
240
257
  }
241
-
242
- if (col.kind === 'operation') {
243
- const alias = col.alias ?? 'expr'
244
- const value = group.length > 0 ? evaluateExpr(col.expr, group[0]) : undefined
245
- resultRow[alias] = value
246
- continue
247
- }
248
258
  }
249
259
 
250
260
  // Apply HAVING filter before adding to projected results
@@ -269,18 +279,8 @@ function evaluateSelectAst(select, dataSource) {
269
279
  for (const key of keys) {
270
280
  outRow[key] = row.getCell(key)
271
281
  }
272
- } else if (col.kind === 'column') {
273
- const name = col.column
274
- const alias = col.alias ?? name
275
- outRow[alias] = row.getCell(name)
276
- } else if (col.kind === 'function') {
277
- /** @type {FunctionNode} */
278
- const funcNode = { type: 'function', name: col.func, args: col.args }
279
- const value = evaluateExpr(funcNode, row)
280
- const alias = col.alias ?? defaultFunctionAlias(col)
281
- outRow[alias] = value
282
- } else if (col.kind === 'operation') {
283
- const alias = col.alias ?? 'expr'
282
+ } else if (col.kind === 'derived') {
283
+ const alias = col.alias ?? defaultDerivedAlias(col.expr)
284
284
  const value = evaluateExpr(col.expr, row)
285
285
  outRow[alias] = value
286
286
  } else if (col.kind === 'aggregate') {
@@ -131,23 +131,23 @@ export function evaluateExpr(node, row) {
131
131
  return String(val).length
132
132
  }
133
133
 
134
- if (funcName === 'SUBSTRING') {
134
+ if (funcName === 'SUBSTRING' || funcName === 'SUBSTR') {
135
135
  if (args.length < 2 || args.length > 3) {
136
- throw new Error('SUBSTRING requires 2 or 3 arguments')
136
+ throw new Error(`${funcName} requires 2 or 3 arguments`)
137
137
  }
138
138
  const str = args[0]
139
139
  if (str == null) return null
140
140
  const strVal = String(str)
141
141
  const start = Number(args[1])
142
142
  if (!Number.isInteger(start) || start < 1) {
143
- throw new Error('SUBSTRING start position must be a positive integer')
143
+ throw new Error(`${funcName} start position must be a positive integer`)
144
144
  }
145
145
  // SQL uses 1-based indexing
146
146
  const startIdx = start - 1
147
147
  if (args.length === 3) {
148
148
  const len = Number(args[2])
149
149
  if (!Number.isInteger(len) || len < 0) {
150
- throw new Error('SUBSTRING length must be a non-negative integer')
150
+ throw new Error(`${funcName} length must be a non-negative integer`)
151
151
  }
152
152
  return strVal.substring(startIdx, startIdx + len)
153
153
  }
@@ -1,5 +1,7 @@
1
+ import { isAggregateFunc, isStringFunc } from '../validation.js'
2
+
1
3
  /**
2
- * @import { ExprCursor, ExprNode, BinaryOp, SelectStatement } from '../types.js'
4
+ * @import { ExprCursor, ExprNode, BinaryOp } from '../types.js'
3
5
  */
4
6
 
5
7
  /**
@@ -11,13 +13,10 @@ export function parseExpression(c) {
11
13
  }
12
14
 
13
15
  /**
14
- * Exposed so SELECT list parsing can reuse the same notion of "primary"
15
- * for function arguments, etc.
16
- *
17
16
  * @param {ExprCursor} c
18
17
  * @returns {ExprNode}
19
18
  */
20
- export function parsePrimary(c) {
19
+ function parsePrimary(c) {
21
20
  const tok = c.current()
22
21
 
23
22
  if (tok.type === 'paren' && tok.value === '(') {
@@ -48,7 +47,12 @@ export function parsePrimary(c) {
48
47
  // function call
49
48
  if (next.type === 'paren' && next.value === '(') {
50
49
  const funcName = tok.value
51
- // TODO: validate function name
50
+
51
+ // validate function names
52
+ if (!isStringFunc(funcName) && !isAggregateFunc(funcName)) {
53
+ throw new Error(`Unknown function "${funcName}" at position ${tok.position}`)
54
+ }
55
+
52
56
  c.consume() // function name
53
57
  c.consume() // '('
54
58
 
@@ -1,11 +1,11 @@
1
+ import { tokenize } from './tokenize.js'
2
+ import { parseExpression } from './expression.js'
3
+ import { isAggregateFunc } from '../validation.js'
4
+
1
5
  /**
2
- * @import { AggregateColumn, AggregateArg, AggregateFunc, ExprCursor, ExprNode, FromSubquery, JoinClause, JoinType, OrderByItem, ParserState, SelectStatement, SelectColumn, StringFunc, Token, TokenType } from '../types.js'
6
+ * @import { AggregateColumn, AggregateArg, AggregateFunc, ExprCursor, ExprNode, FromSubquery, JoinClause, JoinType, OrderByItem, ParserState, SelectStatement, SelectColumn, Token, TokenType } from '../types.js'
3
7
  */
4
8
 
5
- import { tokenize } from './tokenize.js'
6
- import { parseExpression, parsePrimary } from './expression.js'
7
- import { isAggregateFunc, isStringFunc } from '../validation.js'
8
-
9
9
  // Keywords that cannot be used as implicit aliases after a column
10
10
  const RESERVED_AFTER_COLUMN = new Set([
11
11
  'FROM',
@@ -112,6 +112,7 @@ function expectIdentifier(state) {
112
112
 
113
113
  /**
114
114
  * Creates an ExprCursor adapter for the ParserState.
115
+ *
115
116
  * @param {ParserState} state
116
117
  * @returns {ExprCursor}
117
118
  */
@@ -141,6 +142,20 @@ function parseSelectList(state) {
141
142
  const cols = []
142
143
  const tok = current(state)
143
144
 
145
+ // Check for qualified asterisk (table.*)
146
+ if (tok.type === 'identifier') {
147
+ const next = peekToken(state, 1)
148
+ const nextNext = peekToken(state, 2)
149
+ if (next.type === 'dot' && nextNext.type === 'operator' && nextNext.value === '*') {
150
+ const tableTok = consume(state) // consume table name
151
+ consume(state) // consume dot
152
+ consume(state) // consume asterisk
153
+ cols.push({ kind: 'star', table: tableTok.value })
154
+ return cols
155
+ }
156
+ }
157
+
158
+ // Check for unqualified asterisk (*)
144
159
  if (tok.type === 'operator' && tok.value === '*') {
145
160
  consume(state)
146
161
  cols.push({ kind: 'star' })
@@ -166,56 +181,20 @@ function parseSelectItem(state) {
166
181
  throw parseError(state, 'column name or expression')
167
182
  }
168
183
 
169
- if (tok.type === 'identifier' && tok.value === 'CAST') {
170
- expectIdentifier(state) // consume CAST
171
- expect(state, 'paren', '(')
172
- const cursor = createExprCursor(state)
173
- const expr = parseExpression(cursor)
174
- expect(state, 'keyword', 'AS')
175
- const typeTok = expectIdentifier(state)
176
- expect(state, 'paren', ')')
177
- const alias = parseAs(state)
178
- return {
179
- kind: 'operation',
180
- expr: { type: 'cast', expr, toType: typeTok.value },
181
- alias,
182
- }
183
- }
184
-
185
- if (tok.type === 'operator') {
186
- // Handle SELECT expression AS alias
187
- const cursor = createExprCursor(state)
188
- const expr = parseExpression(cursor)
189
- const alias = parseAs(state)
190
- return { kind: 'operation', expr, alias }
191
- }
192
-
193
184
  const next = peekToken(state, 1)
194
- const upper = tok.value.toUpperCase()
195
-
196
185
  if (next.type === 'paren' && next.value === '(') {
197
- expectIdentifier(state) // consume function name
186
+ const upper = tok.value.toUpperCase()
198
187
  if (isAggregateFunc(upper)) {
188
+ expectIdentifier(state) // consume function name
199
189
  return parseAggregateItem(state, upper)
200
190
  }
201
- if (isStringFunc(upper)) {
202
- return parseStringFunctionItem(state, upper)
203
- }
204
- }
205
-
206
- consume(state)
207
- let column = tok.value
208
-
209
- // Handle dot notation (table.column)
210
- if (current(state).type === 'dot') {
211
- consume(state) // consume the dot
212
- const columnTok = expectIdentifier(state)
213
- column += '.' + columnTok.value
214
191
  }
215
192
 
193
+ // Delegate to expression parser
194
+ const cursor = createExprCursor(state)
195
+ const expr = parseExpression(cursor)
216
196
  const alias = parseAs(state)
217
-
218
- return { kind: 'column', column, alias }
197
+ return { kind: 'derived', expr, alias }
219
198
  }
220
199
 
221
200
  /**
@@ -261,34 +240,6 @@ function parseAggregateItem(state, func) {
261
240
  return { kind: 'aggregate', func, arg, alias }
262
241
  }
263
242
 
264
- /**
265
- * @param {ParserState} state
266
- * @param {StringFunc} func
267
- * @returns {SelectColumn}
268
- */
269
- function parseStringFunctionItem(state, func) {
270
- expect(state, 'paren', '(')
271
-
272
- /** @type {ExprNode[]} */
273
- const args = []
274
-
275
- // Parse comma-separated arguments
276
- if (current(state).type !== 'paren' || current(state).value !== ')') {
277
- const cursor = createExprCursor(state)
278
- while (true) {
279
- const arg = parsePrimary(cursor)
280
- args.push(arg)
281
- if (!match(state, 'comma')) break
282
- }
283
- }
284
-
285
- expect(state, 'paren', ')')
286
-
287
- const alias = parseAs(state)
288
-
289
- return { kind: 'function', func, args, alias }
290
- }
291
-
292
243
  /**
293
244
  * @param {ParserState} state
294
245
  * @returns {string | undefined}
@@ -481,9 +432,24 @@ function parseSelectInternal(state) {
481
432
  } else if (match(state, 'keyword', 'DESC')) {
482
433
  direction = 'DESC'
483
434
  }
435
+ /** @type {'FIRST' | 'LAST' | undefined} */
436
+ let nulls
437
+ if (match(state, 'keyword', 'NULLS')) {
438
+ const tok = current(state)
439
+ if (tok.type === 'identifier' && tok.value.toUpperCase() === 'FIRST') {
440
+ consume(state)
441
+ nulls = 'FIRST'
442
+ } else if (tok.type === 'identifier' && tok.value.toUpperCase() === 'LAST') {
443
+ consume(state)
444
+ nulls = 'LAST'
445
+ } else {
446
+ throw parseError(state, 'FIRST or LAST after NULLS')
447
+ }
448
+ }
484
449
  orderBy.push({
485
450
  expr,
486
451
  direction,
452
+ nulls,
487
453
  })
488
454
  if (!match(state, 'comma')) break
489
455
  }
@@ -16,6 +16,7 @@ const KEYWORDS = new Set([
16
16
  'ORDER',
17
17
  'ASC',
18
18
  'DESC',
19
+ 'NULLS',
19
20
  'LIMIT',
20
21
  'OFFSET',
21
22
  'AS',
package/src/types.d.ts CHANGED
@@ -34,26 +34,6 @@ export interface SelectStatement {
34
34
  offset?: number
35
35
  }
36
36
 
37
- export type TokenType =
38
- | 'keyword'
39
- | 'identifier'
40
- | 'number'
41
- | 'string'
42
- | 'operator'
43
- | 'comma'
44
- | 'dot'
45
- | 'paren'
46
- | 'semicolon'
47
- | 'eof'
48
-
49
- export interface Token {
50
- type: TokenType
51
- value: string
52
- position: number
53
- numericValue?: number
54
- originalValue?: string
55
- }
56
-
57
37
  export type BinaryOp =
58
38
  | 'AND'
59
39
  | 'OR'
@@ -139,18 +119,13 @@ export type ExprNode =
139
119
 
140
120
  export interface StarColumn {
141
121
  kind: 'star'
142
- alias?: string
143
- }
144
-
145
- export interface SimpleColumn {
146
- kind: 'column'
147
- column: string
122
+ table?: string
148
123
  alias?: string
149
124
  }
150
125
 
151
126
  export type AggregateFunc = 'COUNT' | 'SUM' | 'AVG' | 'MIN' | 'MAX'
152
127
 
153
- export type StringFunc = 'UPPER' | 'LOWER' | 'CONCAT' | 'LENGTH' | 'SUBSTRING' | 'TRIM'
128
+ export type StringFunc = 'UPPER' | 'LOWER' | 'CONCAT' | 'LENGTH' | 'SUBSTRING' | 'SUBSTR' | 'TRIM'
154
129
 
155
130
  export interface AggregateArgStar {
156
131
  kind: 'star'
@@ -170,24 +145,18 @@ export interface AggregateColumn {
170
145
  alias?: string
171
146
  }
172
147
 
173
- export interface FunctionColumn {
174
- kind: 'function'
175
- func: StringFunc
176
- args: ExprNode[]
177
- alias?: string
178
- }
179
-
180
- export interface OperationColumn {
181
- kind: 'operation'
148
+ export interface DerivedColumn {
149
+ kind: 'derived'
182
150
  expr: ExprNode
183
151
  alias?: string
184
152
  }
185
153
 
186
- export type SelectColumn = StarColumn | SimpleColumn | AggregateColumn | FunctionColumn | OperationColumn
154
+ export type SelectColumn = StarColumn | AggregateColumn | DerivedColumn
187
155
 
188
156
  export interface OrderByItem {
189
157
  expr: ExprNode
190
158
  direction: 'ASC' | 'DESC'
159
+ nulls?: 'FIRST' | 'LAST'
191
160
  }
192
161
 
193
162
  export type JoinType = 'INNER' | 'LEFT' | 'RIGHT' | 'FULL' | 'CROSS'
@@ -212,3 +181,24 @@ export interface ExprCursor {
212
181
  expectIdentifier(): Token
213
182
  parseSubquery?: () => SelectStatement
214
183
  }
184
+
185
+ // Tokenizer types
186
+ export type TokenType =
187
+ | 'keyword'
188
+ | 'identifier'
189
+ | 'number'
190
+ | 'string'
191
+ | 'operator'
192
+ | 'comma'
193
+ | 'dot'
194
+ | 'paren'
195
+ | 'semicolon'
196
+ | 'eof'
197
+
198
+ export interface Token {
199
+ type: TokenType
200
+ value: string
201
+ position: number
202
+ numericValue?: number
203
+ originalValue?: string
204
+ }
package/src/validation.js CHANGED
@@ -13,5 +13,5 @@ export function isAggregateFunc(name) {
13
13
  * @returns {name is StringFunc}
14
14
  */
15
15
  export function isStringFunc(name) {
16
- return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'TRIM'].includes(name)
16
+ return ['UPPER', 'LOWER', 'CONCAT', 'LENGTH', 'SUBSTRING', 'SUBSTR', 'TRIM'].includes(name)
17
17
  }