squirreling 0.8.0 → 0.9.1

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.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -37,10 +37,10 @@
37
37
  "test": "vitest run"
38
38
  },
39
39
  "devDependencies": {
40
- "@types/node": "25.2.2",
40
+ "@types/node": "25.2.3",
41
41
  "@vitest/coverage-v8": "4.0.18",
42
42
  "eslint": "9.39.2",
43
- "eslint-plugin-jsdoc": "62.5.4",
43
+ "eslint-plugin-jsdoc": "62.6.0",
44
44
  "typescript": "5.9.3",
45
45
  "vitest": "4.0.18"
46
46
  }
@@ -1,10 +1,11 @@
1
+ import { derivedAlias } from '../expression/alias.js'
1
2
  import { evaluateExpr } from '../expression/evaluate.js'
2
- import { defaultDerivedAlias, stringify } from './utils.js'
3
3
  import { executePlan } from './execute.js'
4
+ import { stringify } from './utils.js'
4
5
 
5
6
  /**
6
- * @import { AsyncCells, AsyncDataSource, AsyncRow, SelectColumn, UserDefinedFunction } from '../types.js'
7
- * @import { ExecuteContext, HashAggregateNode, ScalarAggregateNode } from '../plan/types.js'
7
+ * @import { AsyncCells, AsyncRow, ExecuteContext, SelectColumn } from '../types.js'
8
+ * @import { HashAggregateNode, ScalarAggregateNode } from '../plan/types.js'
8
9
  */
9
10
 
10
11
  /**
@@ -12,12 +13,10 @@ import { executePlan } from './execute.js'
12
13
  *
13
14
  * @param {SelectColumn[]} selectColumns
14
15
  * @param {AsyncRow[]} group
15
- * @param {Record<string, AsyncDataSource>} tables
16
- * @param {Record<string, UserDefinedFunction>} [functions]
17
- * @param {AbortSignal} [signal]
16
+ * @param {ExecuteContext} context
18
17
  * @returns {AsyncRow}
19
18
  */
20
- function projectAggregateColumns(selectColumns, group, tables, functions, signal) {
19
+ function projectAggregateColumns(selectColumns, group, context) {
21
20
  /** @type {string[]} */
22
21
  const columns = []
23
22
  /** @type {AsyncCells} */
@@ -33,15 +32,13 @@ function projectAggregateColumns(selectColumns, group, tables, functions, signal
33
32
  }
34
33
  }
35
34
  } else if (col.kind === 'derived') {
36
- const alias = col.alias ?? defaultDerivedAlias(col.expr)
35
+ const alias = col.alias ?? derivedAlias(col.expr)
37
36
  columns.push(alias)
38
37
  cells[alias] = () => evaluateExpr({
39
38
  node: col.expr,
40
39
  row: group[0] ?? { columns: [], cells: {} },
41
- tables,
42
- functions,
43
40
  rows: group,
44
- signal,
41
+ context,
45
42
  })
46
43
  }
47
44
  }
@@ -57,13 +54,11 @@ function projectAggregateColumns(selectColumns, group, tables, functions, signal
57
54
  * @yields {AsyncRow}
58
55
  */
59
56
  export async function* executeHashAggregate(plan, context) {
60
- const { tables, functions, signal } = context
61
-
62
57
  // Collect all rows
63
58
  /** @type {AsyncRow[]} */
64
59
  const allRows = []
65
- for await (const row of executePlan(plan.child, context)) {
66
- if (signal?.aborted) return
60
+ for await (const row of executePlan({ plan: plan.child, context })) {
61
+ if (context.signal?.aborted) return
67
62
  allRows.push(row)
68
63
  }
69
64
 
@@ -77,7 +72,7 @@ export async function* executeHashAggregate(plan, context) {
77
72
  /** @type {string[]} */
78
73
  const keyParts = []
79
74
  for (const expr of plan.groupBy) {
80
- const v = await evaluateExpr({ node: expr, row, tables, functions, signal })
75
+ const v = await evaluateExpr({ node: expr, row, context })
81
76
  keyParts.push(stringify(v))
82
77
  }
83
78
  const key = keyParts.join('|')
@@ -92,18 +87,16 @@ export async function* executeHashAggregate(plan, context) {
92
87
 
93
88
  // Yield one row per group
94
89
  for (const group of groups) {
95
- const asyncRow = projectAggregateColumns(plan.columns, group, tables, functions, signal)
90
+ const asyncRow = projectAggregateColumns(plan.columns, group, context)
96
91
 
97
92
  // Apply HAVING filter
98
93
  if (plan.having) {
99
- const context = { ...group[0], ...asyncRow }
94
+ const havingRow = { ...group[0], ...asyncRow }
100
95
  const passes = await evaluateExpr({
101
96
  node: plan.having,
102
- row: context,
97
+ row: havingRow,
103
98
  rows: group,
104
- tables,
105
- functions,
106
- signal,
99
+ context,
107
100
  })
108
101
  if (!passes) continue
109
102
  }
@@ -120,28 +113,24 @@ export async function* executeHashAggregate(plan, context) {
120
113
  * @yields {AsyncRow}
121
114
  */
122
115
  export async function* executeScalarAggregate(plan, context) {
123
- const { tables, functions, signal } = context
124
-
125
116
  // Collect all rows into single group
126
117
  /** @type {AsyncRow[]} */
127
118
  const group = []
128
- for await (const row of executePlan(plan.child, context)) {
129
- if (signal?.aborted) return
119
+ for await (const row of executePlan({ plan: plan.child, context })) {
120
+ if (context.signal?.aborted) return
130
121
  group.push(row)
131
122
  }
132
123
 
133
- const asyncRow = projectAggregateColumns(plan.columns, group, tables, functions, signal)
124
+ const asyncRow = projectAggregateColumns(plan.columns, group, context)
134
125
 
135
126
  // Apply HAVING filter
136
127
  if (plan.having) {
137
- const context = { ...group[0], ...asyncRow }
128
+ const havingRow = { ...group[0], ...asyncRow }
138
129
  const passes = await evaluateExpr({
139
130
  node: plan.having,
140
- row: context,
131
+ row: havingRow,
141
132
  rows: group,
142
- tables,
143
- functions,
144
- signal,
133
+ context,
145
134
  })
146
135
  if (!passes) return
147
136
  }
@@ -1,36 +1,28 @@
1
1
  import { memorySource } from '../backend/dataSource.js'
2
2
  import { tableNotFoundError } from '../executionErrors.js'
3
+ import { derivedAlias } from '../expression/alias.js'
3
4
  import { evaluateExpr } from '../expression/evaluate.js'
4
5
  import { parseSql } from '../parse/parse.js'
5
- import { missingClauseError } from '../parseErrors.js'
6
- import { queryPlan } from '../plan/plan.js'
6
+ import { planSql } from '../plan/plan.js'
7
7
  import { executeHashAggregate, executeScalarAggregate } from './aggregates.js'
8
8
  import { executeHashJoin, executeNestedLoopJoin, executePositionalJoin } from './join.js'
9
9
  import { executeSort } from './sort.js'
10
- import { defaultDerivedAlias, stableRowKey } from './utils.js'
10
+ import { stableRowKey } from './utils.js'
11
11
 
12
12
  /**
13
- * @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteSqlOptions, ExprNode, SelectStatement, UserDefinedFunction } from '../types.js'
14
- * @import { DistinctNode, ExecuteContext, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode } from '../plan/types.js'
13
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, ExecuteContext, ExecuteSqlOptions, ExprNode, SelectStatement } from '../types.js'
14
+ * @import { DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode } from '../plan/types.js'
15
15
  */
16
16
 
17
17
  /**
18
- * Executes a SQL SELECT query against named data sources
18
+ * Executes a SQL SELECT query against tables
19
19
  *
20
- * @param {ExecuteSqlOptions} options - the execution options
21
- * @yields {AsyncRow} async generator yielding result rows
20
+ * @param {ExecuteSqlOptions} options
21
+ * @yields {AsyncRow}
22
22
  */
23
23
  export async function* executeSql({ tables, query, functions, signal }) {
24
24
  const select = typeof query === 'string' ? parseSql({ query, functions }) : query
25
25
 
26
- // Check for unsupported operations
27
- if (!select.from) {
28
- throw missingClauseError({
29
- missing: 'FROM clause',
30
- context: 'SELECT statement',
31
- })
32
- }
33
-
34
26
  // Normalize tables: convert arrays to AsyncDataSource
35
27
  /** @type {Record<string, AsyncDataSource>} */
36
28
  const normalizedTables = {}
@@ -42,7 +34,7 @@ export async function* executeSql({ tables, query, functions, signal }) {
42
34
  }
43
35
  }
44
36
 
45
- yield* executeSelect({ select, tables: normalizedTables, functions, signal })
37
+ yield* executeSelect({ select, context: { tables: normalizedTables, functions, signal } })
46
38
  }
47
39
 
48
40
  /**
@@ -50,24 +42,23 @@ export async function* executeSql({ tables, query, functions, signal }) {
50
42
  *
51
43
  * @param {Object} options
52
44
  * @param {SelectStatement} options.select
53
- * @param {Record<string, AsyncDataSource>} options.tables
54
- * @param {Record<string, UserDefinedFunction>} [options.functions]
55
- * @param {AbortSignal} [options.signal]
45
+ * @param {ExecuteContext} options.context
56
46
  * @yields {AsyncRow}
57
47
  */
58
- export async function* executeSelect({ select, tables, functions, signal }) {
59
- const plan = queryPlan(select)
60
- yield* executePlan(plan, { tables, functions, signal })
48
+ export async function* executeSelect({ select, context }) {
49
+ const plan = planSql({ query: select, functions: context.functions })
50
+ yield* executePlan({ plan, context })
61
51
  }
62
52
 
63
53
  /**
64
54
  * Executes a query plan and yields result rows
65
55
  *
66
- * @param {QueryPlan} plan - the query plan to execute
67
- * @param {ExecuteContext} context - execution context
56
+ * @param {Object} options
57
+ * @param {QueryPlan} options.plan - the query plan to execute
58
+ * @param {ExecuteContext} options.context - execution context
68
59
  * @returns {AsyncGenerator<AsyncRow>}
69
60
  */
70
- export async function* executePlan(plan, context) {
61
+ export async function* executePlan({ plan, context }) {
71
62
  if (plan.type === 'Scan') {
72
63
  yield* executeScan(plan, context)
73
64
  } else if (plan.type === 'Filter') {
@@ -147,7 +138,7 @@ async function* filterRows(rows, condition, context) {
147
138
  for await (const row of rows) {
148
139
  if (context.signal?.aborted) return
149
140
  rowIndex++
150
- const pass = await evaluateExpr({ node: condition, row, rowIndex, ...context })
141
+ const pass = await evaluateExpr({ node: condition, row, rowIndex, context })
151
142
  if (pass) yield row
152
143
  }
153
144
  }
@@ -187,7 +178,7 @@ async function* limitRows(rows, limit, offset, signal) {
187
178
  * @yields {AsyncRow}
188
179
  */
189
180
  async function* executeFilter(plan, context) {
190
- yield* filterRows(executePlan(plan.child, context), plan.condition, context)
181
+ yield* filterRows(executePlan({ plan: plan.child, context }), plan.condition, context)
191
182
  }
192
183
 
193
184
  /**
@@ -198,11 +189,10 @@ async function* executeFilter(plan, context) {
198
189
  * @yields {AsyncRow}
199
190
  */
200
191
  async function* executeProject(plan, context) {
201
- const { tables, functions, signal } = context
202
192
  let rowIndex = 0
203
193
 
204
- for await (const row of executePlan(plan.child, context)) {
205
- if (signal?.aborted) return
194
+ for await (const row of executePlan({ plan: plan.child, context })) {
195
+ if (context.signal?.aborted) return
206
196
  rowIndex++
207
197
  const currentRowIndex = rowIndex
208
198
 
@@ -218,15 +208,13 @@ async function* executeProject(plan, context) {
218
208
  cells[key] = row.cells[key]
219
209
  }
220
210
  } else if (col.kind === 'derived') {
221
- const alias = col.alias ?? defaultDerivedAlias(col.expr)
211
+ const alias = col.alias ?? derivedAlias(col.expr)
222
212
  columns.push(alias)
223
213
  cells[alias] = () => evaluateExpr({
224
214
  node: col.expr,
225
215
  row,
226
- tables,
227
- functions,
228
216
  rowIndex: currentRowIndex,
229
- signal,
217
+ context,
230
218
  })
231
219
  }
232
220
  }
@@ -248,7 +236,7 @@ async function* executeDistinct(plan, context) {
248
236
  /** @type {Set<string>} */
249
237
  const seen = new Set()
250
238
 
251
- for await (const row of executePlan(plan.child, context)) {
239
+ for await (const row of executePlan({ plan: plan.child, context })) {
252
240
  if (signal?.aborted) return
253
241
 
254
242
  const key = await stableRowKey(row.cells)
@@ -267,5 +255,5 @@ async function* executeDistinct(plan, context) {
267
255
  * @yields {AsyncRow}
268
256
  */
269
257
  async function* executeLimit(plan, context) {
270
- yield* limitRows(executePlan(plan.child, context), plan.limit, plan.offset, context.signal)
258
+ yield* limitRows(executePlan({ plan: plan.child, context }), plan.limit, plan.offset, context.signal)
271
259
  }
@@ -1,11 +1,10 @@
1
1
  import { evaluateExpr } from '../expression/evaluate.js'
2
- import { missingClauseError } from '../parseErrors.js'
3
2
  import { stringify } from './utils.js'
4
3
  import { executePlan } from './execute.js'
5
4
 
6
5
  /**
7
- * @import { AsyncCells, AsyncRow } from '../types.js'
8
- * @import { ExecuteContext, HashJoinNode, NestedLoopJoinNode, PositionalJoinNode } from '../plan/types.js'
6
+ * @import { AsyncCells, AsyncRow, ExecuteContext } from '../types.js'
7
+ * @import { HashJoinNode, NestedLoopJoinNode, PositionalJoinNode } from '../plan/types.js'
9
8
  */
10
9
 
11
10
  /**
@@ -16,22 +15,14 @@ import { executePlan } from './execute.js'
16
15
  * @yields {AsyncRow}
17
16
  */
18
17
  export async function* executeNestedLoopJoin(plan, context) {
19
- const { tables, functions, signal } = context
20
18
  const leftTable = plan.leftAlias
21
19
  const rightTable = plan.rightAlias
22
20
 
23
- if (!plan.condition) {
24
- throw missingClauseError({
25
- missing: 'ON condition',
26
- context: 'JOIN',
27
- })
28
- }
29
-
30
21
  // Buffer right rows
31
22
  /** @type {AsyncRow[]} */
32
23
  const rightRows = []
33
- for await (const row of executePlan(plan.right, context)) {
34
- if (signal?.aborted) return
24
+ for await (const row of executePlan({ plan: plan.right, context })) {
25
+ if (context.signal?.aborted) return
35
26
  rightRows.push(row)
36
27
  }
37
28
 
@@ -43,8 +34,8 @@ export async function* executeNestedLoopJoin(plan, context) {
43
34
  /** @type {Set<AsyncRow> | null} */
44
35
  const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : null
45
36
 
46
- for await (const leftRow of executePlan(plan.left, context)) {
47
- if (signal?.aborted) break
37
+ for await (const leftRow of executePlan({ plan: plan.left, context })) {
38
+ if (context.signal?.aborted) break
48
39
 
49
40
  if (!leftPrefixedCols) {
50
41
  leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
@@ -57,9 +48,7 @@ export async function* executeNestedLoopJoin(plan, context) {
57
48
  const matches = await evaluateExpr({
58
49
  node: plan.condition,
59
50
  row: tempMerged,
60
- tables,
61
- functions,
62
- signal,
51
+ context,
63
52
  })
64
53
 
65
54
  if (matches) {
@@ -101,14 +90,14 @@ export async function* executePositionalJoin(plan, context) {
101
90
  // Buffer both sides (required for positional join)
102
91
  /** @type {AsyncRow[]} */
103
92
  const leftRows = []
104
- for await (const row of executePlan(plan.left, context)) {
93
+ for await (const row of executePlan({ plan: plan.left, context })) {
105
94
  if (signal?.aborted) return
106
95
  leftRows.push(row)
107
96
  }
108
97
 
109
98
  /** @type {AsyncRow[]} */
110
99
  const rightRows = []
111
- for await (const row of executePlan(plan.right, context)) {
100
+ for await (const row of executePlan({ plan: plan.right, context })) {
112
101
  if (signal?.aborted) return
113
102
  rightRows.push(row)
114
103
  }
@@ -135,15 +124,14 @@ export async function* executePositionalJoin(plan, context) {
135
124
  * @yields {AsyncRow}
136
125
  */
137
126
  export async function* executeHashJoin(plan, context) {
138
- const { tables, functions, signal } = context
139
127
  const leftTable = plan.leftAlias
140
128
  const rightTable = plan.rightAlias
141
129
 
142
130
  // Buffer right rows and build hash map
143
131
  /** @type {AsyncRow[]} */
144
132
  const rightRows = []
145
- for await (const row of executePlan(plan.right, context)) {
146
- if (signal?.aborted) return
133
+ for await (const row of executePlan({ plan: plan.right, context })) {
134
+ if (context.signal?.aborted) return
147
135
  rightRows.push(row)
148
136
  }
149
137
 
@@ -153,9 +141,7 @@ export async function* executeHashJoin(plan, context) {
153
141
  const keyValue = await evaluateExpr({
154
142
  node: plan.rightKey,
155
143
  row: rightRow,
156
- tables,
157
- functions,
158
- signal,
144
+ context,
159
145
  })
160
146
  if (keyValue == null) continue
161
147
  const keyStr = stringify(keyValue)
@@ -177,8 +163,8 @@ export async function* executeHashJoin(plan, context) {
177
163
  const matchedRightRows = plan.joinType === 'RIGHT' || plan.joinType === 'FULL' ? new Set() : null
178
164
 
179
165
  // Probe phase: stream left rows
180
- for await (const leftRow of executePlan(plan.left, context)) {
181
- if (signal?.aborted) break
166
+ for await (const leftRow of executePlan({ plan: plan.left, context })) {
167
+ if (context.signal?.aborted) break
182
168
 
183
169
  if (!leftPrefixedCols) {
184
170
  leftPrefixedCols = prefixColumns(leftRow.columns, leftTable)
@@ -187,9 +173,7 @@ export async function* executeHashJoin(plan, context) {
187
173
  const keyValue = await evaluateExpr({
188
174
  node: plan.leftKey,
189
175
  row: leftRow,
190
- tables,
191
- functions,
192
- signal,
176
+ context,
193
177
  })
194
178
  const keyStr = stringify(keyValue)
195
179
  const matchingRightRows = hashMap.get(keyStr)
@@ -3,8 +3,8 @@ import { executePlan } from './execute.js'
3
3
  import { compareForTerm } from './utils.js'
4
4
 
5
5
  /**
6
- * @import { AsyncRow, SqlPrimitive } from '../types.js'
7
- * @import { ExecuteContext, SortNode } from '../plan/types.js'
6
+ * @import { AsyncRow, ExecuteContext, SqlPrimitive } from '../types.js'
7
+ * @import { SortNode } from '../plan/types.js'
8
8
  */
9
9
 
10
10
  /**
@@ -15,13 +15,11 @@ import { compareForTerm } from './utils.js'
15
15
  * @yields {AsyncRow}
16
16
  */
17
17
  export async function* executeSort(plan, context) {
18
- const { tables, functions, signal } = context
19
-
20
18
  // Buffer all rows
21
19
  /** @type {AsyncRow[]} */
22
20
  const rows = []
23
- for await (const row of executePlan(plan.child, context)) {
24
- if (signal?.aborted) return
21
+ for await (const row of executePlan({ plan: plan.child, context })) {
22
+ if (context.signal?.aborted) return
25
23
  rows.push(row)
26
24
  }
27
25
 
@@ -51,10 +49,7 @@ export async function* executeSort(plan, context) {
51
49
  evaluatedValues[idx][orderByIdx] = await evaluateExpr({
52
50
  node: term.expr,
53
51
  row: rows[idx],
54
- tables,
55
- functions,
56
- aliases: plan.aliases,
57
- signal,
52
+ context,
58
53
  })
59
54
  }
60
55
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AsyncCells, AsyncRow, ExprNode, OrderByItem, SqlPrimitive } from '../types.js'
2
+ * @import { AsyncCells, AsyncRow, OrderByItem, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -57,45 +57,6 @@ export async function collect(asyncRows) {
57
57
  return results
58
58
  }
59
59
 
60
- /**
61
- * Generates a default alias for a derived column expression
62
- *
63
- * @param {ExprNode} expr - the expression node
64
- * @returns {string} the generated alias
65
- */
66
- export function defaultDerivedAlias(expr) {
67
- if (expr.type === 'identifier') {
68
- // For qualified names like 'users.name', use just the column part as alias
69
- if (expr.name.includes('.')) {
70
- return expr.name.split('.').pop()
71
- }
72
- return expr.name
73
- }
74
- if (expr.type === 'literal') {
75
- return String(expr.value)
76
- }
77
- if (expr.type === 'cast') {
78
- return defaultDerivedAlias(expr.expr) + '_as_' + expr.toType
79
- }
80
- if (expr.type === 'unary') {
81
- return expr.op + '_' + defaultDerivedAlias(expr.argument)
82
- }
83
- if (expr.type === 'binary') {
84
- return defaultDerivedAlias(expr.left) + '_' + expr.op + '_' + defaultDerivedAlias(expr.right)
85
- }
86
- if (expr.type === 'function') {
87
- // Handle aggregate functions with star (COUNT(*) -> count_all)
88
- if (expr.args.length === 1 && expr.args[0].type === 'identifier' && expr.args[0].name === '*') {
89
- return expr.name.toLowerCase() + '_all'
90
- }
91
- return expr.name.toLowerCase() + '_' + expr.args.map(defaultDerivedAlias).join('_')
92
- }
93
- if (expr.type === 'interval') {
94
- return `interval_${expr.value}_${expr.unit.toLowerCase()}`
95
- }
96
- return 'expr'
97
- }
98
-
99
60
  /**
100
61
  * @param {SqlPrimitive} value
101
62
  * @returns {string}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @import { ExprNode } from '../types.js'
3
+ */
4
+
5
+ /**
6
+ * Generates a default alias for a derived column expression
7
+ *
8
+ * @param {ExprNode} expr - the expression node
9
+ * @returns {string} the generated alias
10
+ */
11
+ export function derivedAlias(expr) {
12
+ if (expr.type === 'identifier') {
13
+ // For qualified names like 'users.name', use just the column part as alias
14
+ if (expr.name.includes('.')) {
15
+ return expr.name.split('.').pop()
16
+ }
17
+ return expr.name
18
+ }
19
+ if (expr.type === 'literal') {
20
+ return String(expr.value)
21
+ }
22
+ if (expr.type === 'cast') {
23
+ return derivedAlias(expr.expr) + '_as_' + expr.toType
24
+ }
25
+ if (expr.type === 'unary') {
26
+ return expr.op + '_' + derivedAlias(expr.argument)
27
+ }
28
+ if (expr.type === 'binary') {
29
+ return derivedAlias(expr.left) + '_' + expr.op + '_' + derivedAlias(expr.right)
30
+ }
31
+ if (expr.type === 'function') {
32
+ // Handle aggregate functions with star (COUNT(*) -> count_all)
33
+ if (expr.args.length === 1 && expr.args[0].type === 'identifier' && expr.args[0].name === '*') {
34
+ return expr.name.toLowerCase() + '_all'
35
+ }
36
+ return expr.name.toLowerCase() + '_' + expr.args.map(derivedAlias).join('_')
37
+ }
38
+ if (expr.type === 'interval') {
39
+ return `interval_${expr.value}_${expr.unit.toLowerCase()}`
40
+ }
41
+ return 'expr'
42
+ }