squirreling 0.12.22 → 0.12.24

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/README.md CHANGED
@@ -141,7 +141,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
141
141
  - `SELECT` statements with `DISTINCT`, `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`
142
142
  - `WITH` clause for Common Table Expressions (CTEs)
143
143
  - Subqueries in `SELECT`, `FROM`, `WHERE`, and correlated subqueries
144
- - `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `POSITIONAL JOIN`, `LATERAL VIEW [OUTER] EXPLODE(...)`
144
+ - `JOIN` operations: `INNER JOIN`, `LEFT JOIN`, `RIGHT JOIN`, `FULL JOIN`, `CROSS JOIN`, `POSITIONAL JOIN`, `LATERAL VIEW [OUTER] EXPLODE(...)`, with `ON` or `USING (col, ...)` conditions
145
145
  - `GROUP BY` and `HAVING` clauses
146
146
  - Set operations: `UNION`, `UNION ALL`, `INTERSECT`, `INTERSECT ALL`, `EXCEPT`, `EXCEPT ALL`
147
147
  - Expressions: `CASE`, `CAST`, `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`
@@ -160,7 +160,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
160
160
  - Math: `ABS`, `SIGN`, `CEIL`, `FLOOR`, `ROUND`, `MOD`, `RAND`, `RANDOM`, `LN`, `LOG10`, `EXP`, `POWER`, `SQRT`
161
161
  - Trig: `SIN`, `COS`, `TAN`, `COT`, `ASIN`, `ACOS`, `ATAN`, `ATAN2`, `DEGREES`, `RADIANS`, `PI`
162
162
  - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_DIFF`, `DATEDIFF`, `DATE_PART`, `DATE_TRUNC`, `EPOCH`, `EXTRACT`, `INTERVAL`
163
- - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`
163
+ - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`, `JSON_VALID`, `JSON_TYPE`, `JSON_KEYS`
164
164
  - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_CONTAINS`, `ARRAY_SORT`, `ARRAY_APPEND`, `ARRAY_CONCAT`, `LEN`, `CARDINALITY`, `SIZE`
165
165
  - Table functions: `UNNEST`, `EXPLODE`, `JSON_EACH`
166
166
  - Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.22",
3
+ "version": "0.12.24",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -39,11 +39,11 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/node": "25.9.1",
43
- "@vitest/coverage-v8": "4.1.7",
42
+ "@types/node": "25.9.3",
43
+ "@vitest/coverage-v8": "4.1.8",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.0",
45
+ "eslint-plugin-jsdoc": "63.0.2",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.7"
47
+ "vitest": "4.1.8"
48
48
  }
49
49
  }
package/src/ast.d.ts CHANGED
@@ -210,7 +210,9 @@ export interface JoinClause extends AstBase {
210
210
  table: string
211
211
  alias?: string
212
212
  on?: ExprNode
213
+ using?: string[]
213
214
  fromFunction?: FromFunction
215
+ subquery?: FromSubquery
214
216
  }
215
217
 
216
218
  // All AST node derive from this base, which includes position info for error reporting and other purposes
@@ -55,7 +55,7 @@ export function applyBinaryOp(op, a, b) {
55
55
  .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
56
  .replace(/%/g, '.*')
57
57
  .replace(/_/g, '.')
58
- const regex = new RegExp(`^${regexPattern}$`, 'i')
58
+ const regex = new RegExp(`^${regexPattern}$`, 'is')
59
59
  return regex.test(str)
60
60
  }
61
61
 
@@ -332,7 +332,7 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
332
332
  return values[lower] + (values[upper] - values[lower]) * (pos - lower)
333
333
  }
334
334
 
335
- if (funcName === 'JSON_ARRAYAGG' || funcName === 'ARRAY_AGG') {
335
+ if (funcName === 'JSON_ARRAYAGG' || funcName === 'ARRAY_AGG' || funcName === 'LIST') {
336
336
  const allValues = await evaluateAll(argNode, filteredRows, context)
337
337
  if (node.distinct) {
338
338
  /** @type {SqlPrimitive[]} */
@@ -516,6 +516,25 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
516
516
  return typeof value
517
517
  }
518
518
 
519
+ if (funcName === 'JSON_KEYS') {
520
+ let value = args[0]
521
+ if (value == null) return null
522
+ if (typeof value === 'string') {
523
+ try {
524
+ value = JSON.parse(value)
525
+ } catch {
526
+ throw new ArgValueError({
527
+ ...node,
528
+ message: 'invalid JSON string',
529
+ hint: 'Argument must be valid JSON.',
530
+ rowIndex,
531
+ })
532
+ }
533
+ }
534
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date) return null
535
+ return Object.keys(value)
536
+ }
537
+
519
538
  if (funcName === 'JSON_ARRAY_LENGTH') {
520
539
  let arr = args[0]
521
540
  if (arr == null) return null
@@ -59,6 +59,8 @@ function walkStatement(stmt, cteScope, refs) {
59
59
  for (const j of stmt.joins) {
60
60
  if (j.fromFunction) {
61
61
  for (const a of j.fromFunction.args) walkExpr(a, cteScope, refs)
62
+ } else if (j.subquery) {
63
+ walkStatement(j.subquery.query, cteScope, refs)
62
64
  } else if (!cteScope.has(j.table.toLowerCase())) {
63
65
  refs.add(j.table)
64
66
  }
@@ -2,11 +2,11 @@ import { expectNoAggregate } from '../validation/aggregates.js'
2
2
  import { isTableFunction, validateFunctionArgs } from '../validation/functions.js'
3
3
  import { ParseError } from '../validation/parseErrors.js'
4
4
  import { parseExpression } from './expression.js'
5
- import { isTableFunctionStart, parseFromFunction, parseTableAlias, tableFunctionColumnCount, tableFunctionDefaultColumns } from './parse.js'
5
+ import { isTableFunctionStart, parseFromFunction, parseStatement, parseTableAlias, tableFunctionColumnCount, tableFunctionDefaultColumns } from './parse.js'
6
6
  import { consume, current, expect, match } from './state.js'
7
7
 
8
8
  /**
9
- * @import { ExprNode, FromFunction, JoinClause, JoinType, ParserState } from '../types.js'
9
+ * @import { ExprNode, FromFunction, FromSubquery, JoinClause, JoinType, ParserState } from '../types.js'
10
10
  */
11
11
 
12
12
  /**
@@ -218,26 +218,74 @@ export function parseJoins(state) {
218
218
  })
219
219
  }
220
220
 
221
- // Parse table name and optional alias
222
- const tableTok = expect(state, 'identifier')
223
- const tableAlias = parseTableAlias(state)
221
+ // Subquery on the right side: JOIN (SELECT ...) AS alias ON ...
222
+ const rightTok = current(state)
223
+ /** @type {FromSubquery | undefined} */
224
+ let subquery
225
+ let tableName
226
+ /** @type {string | undefined} */
227
+ let tableAlias
228
+ let endPos
229
+ if (rightTok.type === 'paren' && rightTok.value === '(') {
230
+ consume(state)
231
+ const query = parseStatement(state)
232
+ expect(state, 'paren', ')')
233
+ tableAlias = parseTableAlias(state)
234
+ if (!tableAlias) {
235
+ throw new ParseError({
236
+ message: 'Subquery in JOIN must have an alias',
237
+ positionStart: rightTok.positionStart,
238
+ positionEnd: state.lastPos,
239
+ })
240
+ }
241
+ endPos = state.lastPos
242
+ subquery = {
243
+ type: 'subquery',
244
+ query,
245
+ alias: tableAlias,
246
+ positionStart: rightTok.positionStart,
247
+ positionEnd: endPos,
248
+ }
249
+ tableName = tableAlias
250
+ } else {
251
+ // Parse table name and optional alias
252
+ const tableTok = expect(state, 'identifier')
253
+ tableName = tableTok.value
254
+ tableAlias = parseTableAlias(state)
255
+ endPos = tableTok.positionEnd
256
+ }
224
257
 
225
- // Parse ON condition (not for POSITIONAL joins)
258
+ // Parse ON condition or USING column list (not for POSITIONAL joins)
226
259
  /** @type {ExprNode | undefined} */
227
260
  let condition
261
+ /** @type {string[] | undefined} */
262
+ let using
228
263
  if (joinType !== 'POSITIONAL') {
229
- expect(state, 'keyword', 'ON')
230
- condition = parseExpression(state)
231
- expectNoAggregate(condition, 'JOIN ON')
264
+ if (match(state, 'keyword', 'USING')) {
265
+ expect(state, 'paren', '(')
266
+ using = []
267
+ while (true) {
268
+ const colTok = expect(state, 'identifier')
269
+ using.push(colTok.value)
270
+ if (!match(state, 'comma')) break
271
+ }
272
+ expect(state, 'paren', ')')
273
+ } else {
274
+ expect(state, 'keyword', 'ON')
275
+ condition = parseExpression(state)
276
+ expectNoAggregate(condition, 'JOIN ON')
277
+ }
232
278
  }
233
279
 
234
280
  joins.push({
235
281
  joinType,
236
- table: tableTok.value,
282
+ table: tableName,
237
283
  alias: tableAlias,
238
284
  on: condition,
285
+ using,
286
+ subquery,
239
287
  positionStart: tok.positionStart,
240
- positionEnd: tableTok.positionEnd,
288
+ positionEnd: endPos,
241
289
  })
242
290
  }
243
291
 
@@ -153,6 +153,13 @@ export function extractColumns({ select, parentColumns }) {
153
153
  if (sourceAlias !== undefined) visibleLateralAliases.push(sourceAlias)
154
154
  for (const join of select.joins) {
155
155
  collectColumnsFromExpr(join.on, identifiers)
156
+ // USING columns are equi-join keys on both sides; keep them in every
157
+ // table's needed set so projection pushdown can't prune the join key.
158
+ if (join.using) {
159
+ for (const col of join.using) {
160
+ for (const [, set] of perTable) set?.add(col)
161
+ }
162
+ }
156
163
  const joinAlias = join.alias ?? join.table
157
164
  if (join.fromFunction) {
158
165
  /** @type {IdentifierNode[]} */
@@ -413,6 +420,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
413
420
  for (const col of tableFunctionColumnNames(join.fromFunction)) {
414
421
  result.push(`${joinAlias}.${col}`)
415
422
  }
423
+ } else if (join.subquery) {
424
+ for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
425
+ result.push(`${joinAlias}.${col}`)
426
+ }
416
427
  } else {
417
428
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
418
429
  result.push(`${joinAlias}.${col}`)
@@ -439,6 +450,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
439
450
  for (const col of tableFunctionColumnNames(join.fromFunction)) {
440
451
  result.push(`${joinAlias}.${col}`)
441
452
  }
453
+ } else if (join.subquery) {
454
+ for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
455
+ result.push(`${joinAlias}.${col}`)
456
+ }
442
457
  } else {
443
458
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
444
459
  result.push(`${joinAlias}.${col}`)
package/src/plan/plan.js CHANGED
@@ -447,23 +447,55 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
447
447
  continue
448
448
  }
449
449
 
450
- const ctePlan = ctePlans?.get(join.table.toLowerCase())
451
- /** @type {ScanOptions} */
452
- const rightHints = {}
453
- if (!ctePlan) {
454
- rightHints.columns = perTableColumns.get(rightTable)
455
- validateScan({ ...join, hints: rightHints, tables })
450
+ /** @type {QueryPlan} */
451
+ let rightScan
452
+ if (join.subquery) {
453
+ // Subquery on the right side of the join (derived table). Mirror the
454
+ // FROM-clause subquery handling: plan the inner statement, push down the
455
+ // columns the outer query needs, and wrap in the inner scope so
456
+ // correlated subqueries inside resolve against the right aliases.
457
+ let subColumns = perTableColumns.get(rightTable)
458
+ // Empty array means no columns referenced, but the derived table still
459
+ // needs its own columns. Treat empty as unrestricted.
460
+ if (subColumns?.length === 0) subColumns = undefined
461
+ const subPlan = planStatement({
462
+ stmt: join.subquery.query,
463
+ ctePlans,
464
+ cteColumns,
465
+ tables,
466
+ outerScope,
467
+ parentColumns: subColumns?.map(name => ({ type: 'identifier', name, positionStart: 0, positionEnd: 0 })),
468
+ })
469
+ const availableColumns = inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })
470
+ if (subColumns && availableColumns.length) {
471
+ const missingColumn = subColumns.find(col => !availableColumns.includes(col))
472
+ if (missingColumn) {
473
+ throw new ColumnNotFoundError({ missingColumn, availableColumns, ...join.subquery })
474
+ }
475
+ }
476
+ const innerScope = statementScope(join.subquery.query)
477
+ rightScan = innerScope ? { type: 'Subquery', scope: innerScope, child: subPlan } : subPlan
456
478
  } else {
457
- // For CTE joins, use CTE column metadata for hints
458
- rightHints.columns = perTableColumns.get(rightTable) ?? cteColumns?.get(join.table.toLowerCase())
479
+ const ctePlan = ctePlans?.get(join.table.toLowerCase())
480
+ /** @type {ScanOptions} */
481
+ const rightHints = {}
482
+ if (!ctePlan) {
483
+ rightHints.columns = perTableColumns.get(rightTable)
484
+ validateScan({ ...join, hints: rightHints, tables })
485
+ } else {
486
+ // For CTE joins, use CTE column metadata for hints
487
+ rightHints.columns = perTableColumns.get(rightTable) ?? cteColumns?.get(join.table.toLowerCase())
488
+ }
489
+ rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
459
490
  }
460
- /** @type {QueryPlan} */
461
- const rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
462
491
 
463
492
  if (join.joinType === 'POSITIONAL') {
464
493
  plan = { type: 'PositionalJoin', leftAlias: currentLeftTable, rightAlias: rightTable, left: plan, right: rightScan }
465
494
  } else {
466
- const keys = join.on && extractEquiKeys({ condition: join.on, leftTable: currentLeftTable, rightTable })
495
+ // `USING (cols)` desugars to an equi-condition `left.col = right.col` per
496
+ // column, which routes through the hash-join path like any other ON.
497
+ const condition = join.on ?? (join.using && buildUsingCondition(join.using, join))
498
+ const keys = condition && extractEquiKeys({ condition, leftTable: currentLeftTable, rightTable })
467
499
  if (keys) {
468
500
  /** @type {HashJoinNode} */
469
501
  const hashJoin = {
@@ -484,7 +516,7 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
484
516
  joinType: join.joinType,
485
517
  leftAlias: currentLeftTable,
486
518
  rightAlias: rightTable,
487
- condition: join.on,
519
+ condition,
488
520
  left: plan,
489
521
  right: rightScan,
490
522
  }
@@ -623,6 +655,37 @@ function normalizeIdentifiers(node, sourceColumns) {
623
655
  return node
624
656
  }
625
657
 
658
+ /**
659
+ * Builds the join condition for a `JOIN ... USING (cols)` clause: an AND of
660
+ * `col = col` equalities using unprefixed identifiers. The hash-join path
661
+ * evaluates the left key against the left row and the right key against the
662
+ * right row, so each unqualified name resolves unambiguously on its own side.
663
+ *
664
+ * @param {string[]} using - shared column names from the USING clause
665
+ * @param {{ positionStart: number, positionEnd: number }} pos - position info for the synthesized exprs
666
+ * @returns {ExprNode | undefined}
667
+ */
668
+ function buildUsingCondition(using, pos) {
669
+ const { positionStart, positionEnd } = pos
670
+ /** @type {ExprNode | undefined} */
671
+ let condition
672
+ for (const col of using) {
673
+ /** @type {ExprNode} */
674
+ const eq = {
675
+ type: 'binary',
676
+ op: '=',
677
+ left: { type: 'identifier', name: col, positionStart, positionEnd },
678
+ right: { type: 'identifier', name: col, positionStart, positionEnd },
679
+ positionStart,
680
+ positionEnd,
681
+ }
682
+ condition = condition === undefined
683
+ ? eq
684
+ : { type: 'binary', op: 'AND', left: condition, right: eq, positionStart, positionEnd }
685
+ }
686
+ return condition
687
+ }
688
+
626
689
  /**
627
690
  * Splits a join ON expression into equi-key pairs and a residual predicate so
628
691
  * the planner can route AND-of-equis (with optional range/inequality
package/src/types.d.ts CHANGED
@@ -129,7 +129,7 @@ export interface UserDefinedFunction {
129
129
  arguments: FunctionSignature
130
130
  }
131
131
 
132
- export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'ARRAY_AGG' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
132
+ export type AggregateFunc = 'COUNT' | 'COUNTIF' | 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'ARRAY_AGG' | 'LIST' | 'JSON_ARRAYAGG' | 'STDDEV_SAMP' | 'STDDEV_POP' | 'MEDIAN' | 'PERCENTILE_CONT' | 'APPROX_QUANTILE' | 'STRING_AGG'
133
133
 
134
134
  export type RegExpFunction = 'REGEXP_SUBSTR' | 'REGEXP_EXTRACT' | 'REGEXP_REPLACE' | 'REGEXP_MATCHES'
135
135
 
@@ -11,7 +11,7 @@ export const niladicFuncs = ['CURRENT_DATE', 'CURRENT_TIME', 'CURRENT_TIMESTAMP'
11
11
  * @returns {name is AggregateFunc}
12
12
  */
13
13
  export function isAggregateFunc(name) {
14
- return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'ARRAY_AGG', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
14
+ return ['COUNT', 'COUNTIF', 'SUM', 'AVG', 'MIN', 'MAX', 'ARRAY_AGG', 'LIST', 'JSON_ARRAYAGG', 'STDDEV_SAMP', 'STDDEV_POP', 'MEDIAN', 'PERCENTILE_CONT', 'APPROX_QUANTILE', 'STRING_AGG'].includes(name)
15
15
  }
16
16
 
17
17
  /**
@@ -179,8 +179,10 @@ export const FUNCTION_SIGNATURES = {
179
179
  JSON_ARRAY_LENGTH: { min: 1, max: 1, signature: 'array' },
180
180
  JSON_VALID: { min: 1, max: 1, signature: 'value' },
181
181
  JSON_TYPE: { min: 1, max: 1, signature: 'value' },
182
+ JSON_KEYS: { min: 1, max: 1, signature: 'value' },
182
183
  JSON_ARRAYAGG: { min: 1, max: 1, signature: 'expression' },
183
184
  ARRAY_AGG: { min: 1, max: 1, signature: 'expression' },
185
+ LIST: { min: 1, max: 1, signature: 'expression' },
184
186
 
185
187
  // Array functions
186
188
  ARRAY_LENGTH: { min: 1, max: 2, signature: 'array[, dimension]' },
@@ -3,7 +3,7 @@ export const KEYWORDS = new Set([
3
3
  'HAVING', 'ORDER', 'ASC', 'DESC', 'NULLS', 'LIMIT', 'OFFSET', 'AS', 'ALL',
4
4
  'DISTINCT', 'TRUE', 'FALSE', 'NULL', 'LIKE', 'IN', 'EXISTS', 'BETWEEN',
5
5
  'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'JOIN', 'INNER', 'LEFT', 'RIGHT',
6
- 'FULL', 'OUTER', 'CROSS', 'POSITIONAL', 'LATERAL', 'VIEW', 'ON', 'INTERVAL', 'DAY', 'MONTH', 'YEAR',
6
+ 'FULL', 'OUTER', 'CROSS', 'POSITIONAL', 'LATERAL', 'VIEW', 'ON', 'USING', 'INTERVAL', 'DAY', 'MONTH', 'YEAR',
7
7
  'HOUR', 'MINUTE', 'SECOND', 'FILTER', 'WITHIN',
8
8
  'UNION', 'INTERSECT', 'EXCEPT',
9
9
  ])
@@ -17,7 +17,7 @@ export const RESERVED_KEYWORDS = new Set([
17
17
  'EXISTS', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'INTERVAL',
18
18
  'GROUP', 'BY', 'HAVING', 'ORDER', 'LIMIT', 'OFFSET',
19
19
  'AS', 'ALL', 'DISTINCT',
20
- 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'ON',
20
+ 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'ON', 'USING',
21
21
  'UNION', 'INTERSECT', 'EXCEPT',
22
22
  ])
23
23
 
@@ -30,6 +30,6 @@ export const RESERVED_AFTER_COLUMN = new Set([
30
30
  // Keywords that cannot be used as table aliases
31
31
  export const RESERVED_AFTER_TABLE = new Set([
32
32
  'WHERE', 'GROUP', 'HAVING', 'ORDER', 'LIMIT', 'OFFSET', 'JOIN', 'INNER',
33
- 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'ON', 'POSITIONAL', 'LATERAL',
33
+ 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'ON', 'USING', 'POSITIONAL', 'LATERAL',
34
34
  'UNION', 'INTERSECT', 'EXCEPT',
35
35
  ])