squirreling 0.6.0 → 0.7.0

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,21 +1,21 @@
1
1
  import { tokenize } from './tokenize.js'
2
2
  import { parseExpression } from './expression.js'
3
- import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE, isAggregateFunc } from '../validation.js'
3
+ import { RESERVED_AFTER_COLUMN, RESERVED_AFTER_TABLE } from '../validation.js'
4
4
  import { consume, current, expect, expectIdentifier, match, parseError, peekToken } from './state.js'
5
5
  import { parseJoins } from './joins.js'
6
6
 
7
7
  /**
8
- * @import { AggregateColumn, AggregateArg, AggregateFunc, ExprNode, FromSubquery, FromTable, OrderByItem, ParserState, SelectStatement, SelectColumn } from '../types.js'
8
+ * @import { ExprNode, FromSubquery, FromTable, OrderByItem, ParserState, SelectStatement, SelectColumn, UserDefinedFunction } from '../types.js'
9
9
  */
10
10
 
11
11
  /**
12
- * @param {string} query
12
+ * @param {{ query: string, functions?: Record<string, UserDefinedFunction> }} options
13
13
  * @returns {SelectStatement}
14
14
  */
15
- export function parseSql(query) {
15
+ export function parseSql({ query, functions }) {
16
16
  const tokens = tokenize(query)
17
17
  /** @type {ParserState} */
18
- const state = { tokens, pos: 0 }
18
+ const state = { tokens, pos: 0, functions }
19
19
  const select = parseSelectInternal(state)
20
20
 
21
21
  const tok = current(state)
@@ -79,61 +79,12 @@ function parseSelectItem(state) {
79
79
  throw parseError(state, 'column name or expression')
80
80
  }
81
81
 
82
- const next = peekToken(state, 1)
83
- if (next.type === 'paren' && next.value === '(') {
84
- const upper = tok.value.toUpperCase()
85
- if (isAggregateFunc(upper)) {
86
- expectIdentifier(state) // consume function name
87
- return parseAggregateItem(state, upper)
88
- }
89
- }
90
-
91
- // Delegate to expression parser
82
+ // Delegate to expression parser (handles all expressions including aggregates)
92
83
  const expr = parseExpression(state)
93
84
  const alias = parseAs(state)
94
85
  return { kind: 'derived', expr, alias }
95
86
  }
96
87
 
97
- /**
98
- * @param {ParserState} state
99
- * @param {AggregateFunc} func
100
- * @returns {AggregateColumn}
101
- */
102
- function parseAggregateItem(state, func) {
103
- expect(state, 'paren', '(')
104
-
105
- /** @type {AggregateArg} */
106
- let arg
107
-
108
- const cur = current(state)
109
- if (cur.type === 'operator' && cur.value === '*') {
110
- consume(state)
111
- arg = { kind: 'star' }
112
- } else {
113
- /** @type {'all' | 'distinct'} */
114
- let quantifier = 'all'
115
- if (cur.type === 'keyword' && cur.value === 'ALL') {
116
- consume(state) // consume ALL
117
- } else if (cur.type === 'keyword' && cur.value === 'DISTINCT') {
118
- consume(state)
119
- quantifier = 'distinct'
120
- }
121
-
122
- const expr = parseExpression(state)
123
- arg = {
124
- kind: 'expression',
125
- expr,
126
- quantifier,
127
- }
128
- }
129
-
130
- expect(state, 'paren', ')')
131
-
132
- const alias = parseAs(state)
133
-
134
- return { kind: 'aggregate', func, arg, alias }
135
- }
136
-
137
88
  /**
138
89
  * Parses an optional table alias (e.g., "FROM users u" or "FROM users AS u")
139
90
  * @param {ParserState} state
@@ -2,6 +2,8 @@
2
2
  // PARSE ERRORS - Issues during SQL tokenization and parsing
3
3
  // ============================================================================
4
4
 
5
+ import { FUNCTION_SIGNATURES } from './validationErrors.js'
6
+
5
7
  /**
6
8
  * Structured parse error with position range.
7
9
  */
@@ -103,6 +105,33 @@ export function unknownFunctionError({ funcName, positionStart, positionEnd, val
103
105
  })
104
106
  }
105
107
 
108
+ /**
109
+ * Error for wrong number of function arguments at parse time.
110
+ *
111
+ * @param {Object} options
112
+ * @param {string} options.funcName - The function name
113
+ * @param {number | string} options.expected - Expected count (number or range like "2 to 3")
114
+ * @param {number} options.received - Actual argument count
115
+ * @param {number} options.positionStart - Start position in query
116
+ * @param {number} options.positionEnd - End position in query
117
+ * @returns {ParseError}
118
+ */
119
+ export function argCountParseError({ funcName, expected, received, positionStart, positionEnd }) {
120
+ const signature = FUNCTION_SIGNATURES[funcName] ?? ''
121
+ let expectedStr = `${expected} arguments`
122
+ if (expected === 0) expectedStr = 'no arguments'
123
+ if (expected === 1) expectedStr = '1 argument'
124
+ if (typeof expected === 'string' && expected.endsWith(' 1')) {
125
+ expectedStr = `${expected} argument`
126
+ }
127
+
128
+ return new ParseError({
129
+ message: `${funcName}(${signature}) function requires ${expectedStr}, got ${received}`,
130
+ positionStart,
131
+ positionEnd,
132
+ })
133
+ }
134
+
106
135
  /**
107
136
  * Error for missing required clause or structure.
108
137
  *
package/src/types.d.ts CHANGED
@@ -1,4 +1,35 @@
1
+ // User-defined function type
2
+ export type UserDefinedFunction = (...args: SqlPrimitive[]) => SqlPrimitive | Promise<SqlPrimitive>
3
+
4
+ // executeSql(options)
5
+ export interface ExecuteSqlOptions {
6
+ tables: Record<string, Row | AsyncDataSource>
7
+ query: string | SelectStatement
8
+ functions?: Record<string, UserDefinedFunction>
9
+ signal?: AbortSignal
10
+ }
11
+
12
+ // AsyncRow represents a row with async cell values
13
+ export interface AsyncRow {
14
+ columns: string[]
15
+ cells: AsyncCells
16
+ }
17
+ export type AsyncCells = Record<string, AsyncCell>
18
+ export type AsyncCell = () => Promise<SqlPrimitive>
19
+
20
+ export type Row = Record<string, SqlPrimitive>[]
1
21
 
22
+ /**
23
+ * Async data source for streaming SQL execution.
24
+ * Provides an async iterator over rows.
25
+ */
26
+ export interface AsyncDataSource {
27
+ scan(options: ScanOptions): AsyncIterable<AsyncRow>
28
+ }
29
+ export interface ScanOptions {
30
+ hints?: QueryHints
31
+ signal?: AbortSignal
32
+ }
2
33
  /**
3
34
  * Hints passed to data sources for query optimization.
4
35
  * All hints are optional and "best effort" - sources may ignore them.
@@ -15,27 +46,6 @@ export interface QueryHints {
15
46
  offset?: number
16
47
  }
17
48
 
18
- /**
19
- * Async data source for streaming SQL execution.
20
- * Provides an async iterator over rows.
21
- */
22
- export interface AsyncDataSource {
23
- scan(hints?: QueryHints): AsyncIterable<AsyncRow>
24
- }
25
- export interface AsyncRow {
26
- columns: string[]
27
- cells: AsyncCells
28
- }
29
- export type AsyncCells = Record<string, AsyncCell>
30
- export type AsyncCell = () => Promise<SqlPrimitive>
31
-
32
- export type Row = Record<string, SqlPrimitive>[]
33
-
34
- export interface ExecuteSqlOptions {
35
- tables: Record<string, Row | AsyncDataSource>
36
- query: string | SelectStatement
37
- }
38
-
39
49
  export type SqlPrimitive =
40
50
  | string
41
51
  | number
@@ -109,6 +119,7 @@ export interface FunctionNode extends ExprNodeBase {
109
119
  type: 'function'
110
120
  name: string
111
121
  args: ExprNode[]
122
+ distinct?: boolean
112
123
  }
113
124
 
114
125
  export interface CastNode extends ExprNodeBase {
@@ -220,32 +231,13 @@ export type StringFunc =
220
231
  | 'CURRENT_TIME'
221
232
  | 'CURRENT_TIMESTAMP'
222
233
 
223
- export interface AggregateArgStar {
224
- kind: 'star'
225
- }
226
-
227
- export interface AggregateArgExpression {
228
- kind: 'expression'
229
- expr: ExprNode
230
- quantifier: 'all' | 'distinct'
231
- }
232
-
233
- export type AggregateArg = AggregateArgStar | AggregateArgExpression
234
-
235
- export interface AggregateColumn {
236
- kind: 'aggregate'
237
- func: AggregateFunc
238
- arg: AggregateArg
239
- alias?: string
240
- }
241
-
242
234
  export interface DerivedColumn {
243
235
  kind: 'derived'
244
236
  expr: ExprNode
245
237
  alias?: string
246
238
  }
247
239
 
248
- export type SelectColumn = StarColumn | AggregateColumn | DerivedColumn
240
+ export type SelectColumn = StarColumn | DerivedColumn
249
241
 
250
242
  export interface OrderByItem {
251
243
  expr: ExprNode
@@ -266,6 +258,7 @@ export interface ParserState {
266
258
  tokens: Token[]
267
259
  pos: number
268
260
  lastPos?: number
261
+ functions?: Record<string, UserDefinedFunction>
269
262
  }
270
263
 
271
264
  // Tokenizer types
package/src/validation.js CHANGED
@@ -1,6 +1,6 @@
1
1
 
2
2
  /**
3
- * @import {AggregateFunc, BinaryOp, ComparisonOp, IntervalUnit, MathFunc, StringFunc} from './types.js'
3
+ * @import {AggregateFunc, BinaryOp, ComparisonOp, IntervalUnit, MathFunc, StringFunc, UserDefinedFunction} from './types.js'
4
4
  * @param {string} name
5
5
  * @returns {name is AggregateFunc}
6
6
  */
@@ -62,6 +62,126 @@ export function isBinaryOp(op) {
62
62
  return ['AND', 'OR', 'LIKE', '=', '!=', '<>', '<', '>', '<=', '>='].includes(op)
63
63
  }
64
64
 
65
+ /**
66
+ * Function argument count specifications.
67
+ * min: minimum number of arguments
68
+ * max: maximum number of arguments (null = unlimited)
69
+ * @type {Record<string, {min: number, max: number | null}>}
70
+ */
71
+ export const FUNCTION_ARG_COUNTS = {
72
+ // String functions
73
+ UPPER: { min: 1, max: 1 },
74
+ LOWER: { min: 1, max: 1 },
75
+ LENGTH: { min: 1, max: 1 },
76
+ TRIM: { min: 1, max: 1 },
77
+ REPLACE: { min: 3, max: 3 },
78
+ SUBSTRING: { min: 2, max: 3 },
79
+ SUBSTR: { min: 2, max: 3 },
80
+ CONCAT: { min: 1, max: null },
81
+
82
+ // Date/time functions
83
+ RANDOM: { min: 0, max: 0 },
84
+ RAND: { min: 0, max: 0 },
85
+ CURRENT_DATE: { min: 0, max: 0 },
86
+ CURRENT_TIME: { min: 0, max: 0 },
87
+ CURRENT_TIMESTAMP: { min: 0, max: 0 },
88
+
89
+ // Math functions
90
+ FLOOR: { min: 1, max: 1 },
91
+ CEIL: { min: 1, max: 1 },
92
+ CEILING: { min: 1, max: 1 },
93
+ ABS: { min: 1, max: 1 },
94
+ MOD: { min: 2, max: 2 },
95
+ EXP: { min: 1, max: 1 },
96
+ LN: { min: 1, max: 1 },
97
+ LOG10: { min: 1, max: 1 },
98
+ POWER: { min: 2, max: 2 },
99
+ SQRT: { min: 1, max: 1 },
100
+ SIN: { min: 1, max: 1 },
101
+ COS: { min: 1, max: 1 },
102
+ TAN: { min: 1, max: 1 },
103
+ COT: { min: 1, max: 1 },
104
+ ASIN: { min: 1, max: 1 },
105
+ ACOS: { min: 1, max: 1 },
106
+ ATAN: { min: 1, max: 2 },
107
+ ATAN2: { min: 2, max: 2 },
108
+ DEGREES: { min: 1, max: 1 },
109
+ RADIANS: { min: 1, max: 1 },
110
+ PI: { min: 0, max: 0 },
111
+
112
+ // JSON functions
113
+ JSON_VALUE: { min: 2, max: 2 },
114
+ JSON_QUERY: { min: 2, max: 2 },
115
+ JSON_OBJECT: { min: 0, max: null },
116
+ JSON_ARRAYAGG: { min: 1, max: 1 },
117
+
118
+ // Aggregate functions
119
+ COUNT: { min: 1, max: 1 },
120
+ SUM: { min: 1, max: 1 },
121
+ AVG: { min: 1, max: 1 },
122
+ MIN: { min: 1, max: 1 },
123
+ MAX: { min: 1, max: 1 },
124
+ }
125
+
126
+ /**
127
+ * Format expected argument count for error messages.
128
+ * @param {number} min
129
+ * @param {number | null} max
130
+ * @returns {string | number}
131
+ */
132
+ function formatExpected(min, max) {
133
+ if (max === null) return `at least ${min}`
134
+ if (min === max) return min
135
+ return `${min} or ${max}`
136
+ }
137
+
138
+ /**
139
+ * Validates function argument count.
140
+ * @param {string} funcName - The function name (uppercase)
141
+ * @param {number} argCount - Number of arguments provided
142
+ * @returns {{ valid: boolean, expected: string | number }}
143
+ */
144
+ export function validateFunctionArgCount(funcName, argCount) {
145
+ const spec = FUNCTION_ARG_COUNTS[funcName]
146
+ if (!spec) return { valid: true, expected: 0 }
147
+
148
+ const { min, max } = spec
149
+
150
+ if (argCount < min) {
151
+ return { valid: false, expected: formatExpected(min, max) }
152
+ }
153
+ if (max !== null && argCount > max) {
154
+ return { valid: false, expected: formatExpected(min, max) }
155
+ }
156
+
157
+ return { valid: true, expected: formatExpected(min, max) }
158
+ }
159
+
160
+ /**
161
+ * Checks if a function is known (either built-in or user-defined).
162
+ * @param {string} funcName - The function name (uppercase)
163
+ * @param {Record<string, UserDefinedFunction>} [functions] - User-defined functions
164
+ * @returns {boolean}
165
+ */
166
+ export function isKnownFunction(funcName, functions) {
167
+ // Check built-in functions
168
+ if (isAggregateFunc(funcName) || isMathFunc(funcName) || isStringFunc(funcName)) {
169
+ return true
170
+ }
171
+
172
+ // Special case: CAST is not in any function list but is a built-in
173
+ if (funcName === 'CAST') {
174
+ return true
175
+ }
176
+
177
+ // Check user-defined functions (case-insensitive)
178
+ if (functions) {
179
+ return Object.keys(functions).some(k => k.toUpperCase() === funcName)
180
+ }
181
+
182
+ return false
183
+ }
184
+
65
185
  // Keywords that cannot be used as implicit aliases after a column
66
186
  export const RESERVED_AFTER_COLUMN = new Set([
67
187
  'FROM', 'WHERE', 'GROUP', 'HAVING', 'ORDER', 'LIMIT', 'OFFSET',
@@ -9,7 +9,7 @@ import { ExecutionError } from './executionErrors.js'
9
9
  * Maps function name to its parameter signature.
10
10
  * @type {Record<string, string>}
11
11
  */
12
- const FUNCTION_SIGNATURES = {
12
+ export const FUNCTION_SIGNATURES = {
13
13
  // String functions
14
14
  UPPER: 'string',
15
15
  LOWER: 'string',
@@ -64,30 +64,6 @@ const FUNCTION_SIGNATURES = {
64
64
  MAX: 'expression',
65
65
  }
66
66
 
67
- /**
68
- * Error for wrong number of function arguments.
69
- *
70
- * @param {Object} options
71
- * @param {string} options.funcName - The function name
72
- * @param {number | string} options.expected - Expected count (number or range like "2 or 3")
73
- * @param {number} options.received - Actual argument count
74
- * @param {number} options.positionStart - Start position in query
75
- * @param {number} options.positionEnd - End position in query
76
- * @param {number} [options.rowNumber] - 1-based row number where error occurred
77
- * @returns {ExecutionError}
78
- */
79
- export function argCountError({ funcName, expected, received, positionStart, positionEnd, rowNumber }) {
80
- const signature = FUNCTION_SIGNATURES[funcName] ?? ''
81
- let expectedStr = `${expected} arguments`
82
- if (expected === 0) expectedStr = 'no arguments'
83
- if (expected === 1) expectedStr = '1 argument'
84
- if (typeof expected === 'string' && expected.endsWith(' 1')) {
85
- expectedStr = `${expected} argument`
86
- }
87
-
88
- return new ExecutionError({ message: `${funcName}(${signature}) function requires ${expectedStr}, got ${received}`, positionStart, positionEnd, rowNumber })
89
- }
90
-
91
67
  /**
92
68
  * Error for invalid argument type or value.
93
69
  *
@@ -1,119 +0,0 @@
1
- import { unknownFunctionError } from '../parseErrors.js'
2
- import { aggregateError } from '../validationErrors.js'
3
- import { evaluateExpr } from './expression.js'
4
- import { defaultDerivedAlias, stringify } from './utils.js'
5
-
6
- /**
7
- * Evaluates an aggregate function over a set of rows
8
- *
9
- * @import { AggregateColumn, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
10
- * @param {Object} options
11
- * @param {AggregateColumn} options.col - aggregate column definition
12
- * @param {AsyncRow[]} options.rows - rows to aggregate
13
- * @param {Record<string, AsyncDataSource>} options.tables
14
- * @returns {Promise<SqlPrimitive>} aggregated result
15
- */
16
- export async function evaluateAggregate({ col, rows, tables }) {
17
- const { arg, func } = col
18
-
19
- if (func === 'COUNT') {
20
- if (arg.kind === 'star') return rows.length
21
- if (arg.quantifier === 'distinct') {
22
- const seen = new Set()
23
- for (const row of rows) {
24
- const v = await evaluateExpr({ node: arg.expr, row, tables })
25
- if (v != null) {
26
- seen.add(v)
27
- }
28
- }
29
- return seen.size
30
- }
31
- let count = 0
32
- for (const row of rows) {
33
- const v = await evaluateExpr({ node: arg.expr, row, tables })
34
- if (v != null) {
35
- count += 1
36
- }
37
- }
38
- return count
39
- }
40
-
41
- if (func === 'SUM' || func === 'AVG' || func === 'MIN' || func === 'MAX') {
42
- if (arg.kind === 'star') {
43
- throw aggregateError({ funcName: func, issue: '(*) is not supported, use a column name' })
44
- }
45
- let sum = 0
46
- let count = 0
47
- /** @type {number | null} */
48
- let min = null
49
- /** @type {number | null} */
50
- let max = null
51
-
52
- for (const row of rows) {
53
- const raw = await evaluateExpr({ node: arg.expr, row, tables })
54
- if (raw == null) continue
55
- const num = Number(raw)
56
- if (!Number.isFinite(num)) continue
57
-
58
- if (count === 0) {
59
- min = num
60
- max = num
61
- } else {
62
- if (min == null || num < min) min = num
63
- if (max == null || num > max) max = num
64
- }
65
- sum += num
66
- count += 1
67
- }
68
-
69
- if (func === 'SUM') return sum
70
- if (func === 'AVG') return count === 0 ? null : sum / count
71
- if (func === 'MIN') return min
72
- if (func === 'MAX') return max
73
- }
74
-
75
- if (func === 'JSON_ARRAYAGG') {
76
- if (arg.kind === 'star') {
77
- throw aggregateError({ funcName: 'JSON_ARRAYAGG', issue: '(*) is not supported, use a column name or expression' })
78
- }
79
- /** @type {SqlPrimitive[]} */
80
- const values = []
81
- if (arg.quantifier === 'distinct') {
82
- const seen = new Set()
83
- for (const row of rows) {
84
- const v = await evaluateExpr({ node: arg.expr, row, tables })
85
- const key = stringify(v)
86
- if (!seen.has(key)) {
87
- seen.add(key)
88
- values.push(v)
89
- }
90
- }
91
- } else {
92
- for (const row of rows) {
93
- const v = await evaluateExpr({ node: arg.expr, row, tables })
94
- values.push(v)
95
- }
96
- }
97
- return values
98
- }
99
-
100
- throw unknownFunctionError({
101
- funcName: func,
102
- positionStart: 0,
103
- positionEnd: 0,
104
- validFunctions: 'COUNT, SUM, AVG, MIN, MAX, JSON_ARRAYAGG',
105
- })
106
- }
107
-
108
- /**
109
- * Generates a default alias name for an aggregate function
110
- * (e.g., "count_all", "sum_amount")
111
- *
112
- * @param {AggregateColumn} col
113
- * @returns {string}
114
- */
115
- export function defaultAggregateAlias(col) {
116
- const base = col.func.toLowerCase()
117
- if (col.arg.kind === 'star') return base + '_all'
118
- return base + '_' + defaultDerivedAlias(col.arg.expr)
119
- }