squirreling 0.12.5 → 0.12.6

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
@@ -161,6 +161,7 @@ Squirreling mostly follows the SQL standard. The following features are supporte
161
161
  - Date: `CURRENT_DATE`, `CURRENT_TIME`, `CURRENT_TIMESTAMP`, `DATE_PART`, `DATE_TRUNC`, `EXTRACT`, `INTERVAL`
162
162
  - Json: `JSON_VALUE`, `JSON_QUERY`, `JSON_EXTRACT`, `JSON_OBJECT`, `JSON_ARRAY_LENGTH`
163
163
  - Array: `ARRAY_LENGTH`, `ARRAY_POSITION`, `ARRAY_SORT`, `CARDINALITY`
164
+ - Table functions: `UNNEST`
164
165
  - Regex: `REGEXP_SUBSTR`, `REGEXP_EXTRACT`, `REGEXP_REPLACE`, `REGEXP_MATCHES`
165
166
  - Spatial: `ST_GeomFromText`, `ST_MakeEnvelope`, `ST_AsText`, `ST_Intersects`, `ST_Contains`, `ST_ContainsProperly`, `ST_Within`, `ST_Overlaps`, `ST_Touches`, `ST_Equals`, `ST_Crosses`, `ST_Covers`, `ST_CoveredBy`, `ST_DWithin`
166
167
  - Conditional: `COALESCE`, `NULLIF`, `GREATEST`, `LEAST`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.5",
3
+ "version": "0.12.6",
4
4
  "description": "Squirreling Async SQL Engine",
5
5
  "author": "Hyperparam",
6
6
  "homepage": "https://hyperparam.app",
@@ -40,10 +40,10 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "25.6.0",
43
- "@vitest/coverage-v8": "4.1.4",
43
+ "@vitest/coverage-v8": "4.1.5",
44
44
  "eslint": "9.39.2",
45
45
  "eslint-plugin-jsdoc": "62.9.0",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.4"
47
+ "vitest": "4.1.5"
48
48
  }
49
49
  }
package/src/ast.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface SelectStatement extends AstBase {
12
12
  type: 'select'
13
13
  distinct: boolean
14
14
  columns: SelectColumn[]
15
- from: FromTable | FromSubquery
15
+ from: FromTable | FromSubquery | FromFunction
16
16
  joins: JoinClause[]
17
17
  where?: ExprNode
18
18
  groupBy: ExprNode[]
@@ -60,6 +60,14 @@ export interface FromSubquery extends AstBase {
60
60
  alias?: string
61
61
  }
62
62
 
63
+ export interface FromFunction extends AstBase {
64
+ type: 'function'
65
+ funcName: string
66
+ args: ExprNode[]
67
+ alias?: string
68
+ columnAlias?: string
69
+ }
70
+
63
71
  export type ArithmeticOp = '+' | '-' | '*' | '/' | '%'
64
72
 
65
73
  export type BinaryOp = 'AND' | 'OR' | 'LIKE' | ComparisonOp | ArithmeticOp
@@ -193,6 +201,7 @@ export interface JoinClause extends AstBase {
193
201
  table: string
194
202
  alias?: string
195
203
  on?: ExprNode
204
+ fromFunction?: FromFunction
196
205
  }
197
206
 
198
207
  // All AST node derive from this base, which includes position info for error reporting and other purposes
@@ -120,11 +120,10 @@ export function executeHashAggregate(plan, context) {
120
120
  */
121
121
  export function executeScalarAggregate(plan, context) {
122
122
  // Fast path: use scanColumn when available
123
- const scalarColumns = selectColumnNames(plan.columns, [])
124
123
  const fast = tryColumnScanAggregate(plan, context)
125
124
  if (fast) {
126
125
  return {
127
- columns: scalarColumns,
126
+ columns: selectColumnNames(plan.columns, []),
128
127
  numRows: 1,
129
128
  maxRows: 1,
130
129
  rows: fast,
@@ -12,7 +12,7 @@ import { addBounds, minBounds, stableRowKey } from './utils.js'
12
12
 
13
13
  /**
14
14
  * @import { AsyncCells, AsyncDataSource, AsyncRow, DerivedColumn, ExecuteContext, ExecuteSqlOptions, ExprNode, IdentifierNode, QueryResults, SelectColumn, SqlPrimitive, Statement } from '../types.js'
15
- * @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode } from '../plan/types.js'
15
+ * @import { CountNode, DistinctNode, FilterNode, LimitNode, ProjectNode, QueryPlan, ScanNode, SetOperationNode, TableFunctionNode } from '../plan/types.js'
16
16
  */
17
17
 
18
18
  /**
@@ -36,8 +36,16 @@ export function executeSql({ tables, query, functions, signal }) {
36
36
  }
37
37
 
38
38
  const scope = statementScope(parsed)
39
- const context = { tables: normalizedTables, functions, signal, scope }
40
- const plan = planSql({ query: parsed, functions, tables: normalizedTables })
39
+ // CTEs are resolved at plan time for FROM/JOIN positions. Subqueries inside
40
+ // expressions are re-planned during execution, so capture the CTE maps here
41
+ // and thread them through the context so those re-plans can still resolve
42
+ // CTE references.
43
+ /** @type {Map<string, QueryPlan>} */
44
+ const ctePlans = new Map()
45
+ /** @type {Map<string, string[]>} */
46
+ const cteColumns = new Map()
47
+ const context = { tables: normalizedTables, functions, signal, scope, ctePlans, cteColumns }
48
+ const plan = planSql({ query: parsed, functions, tables: normalizedTables, ctePlans, cteColumns })
41
49
  return executePlan({ plan, context })
42
50
  }
43
51
 
@@ -51,7 +59,13 @@ export function executeSql({ tables, query, functions, signal }) {
51
59
  * @returns {QueryResults}
52
60
  */
53
61
  export function executeStatement({ query, context, outerScope }) {
54
- const plan = planStatement({ stmt: query, tables: context.tables, outerScope })
62
+ const plan = planStatement({
63
+ stmt: query,
64
+ tables: context.tables,
65
+ ctePlans: context.ctePlans,
66
+ cteColumns: context.cteColumns,
67
+ outerScope,
68
+ })
55
69
  // Compute this query's scope (FROM alias + JOIN aliases) for nested correlated subqueries
56
70
  const scope = statementScope(query)
57
71
  return executePlan({ plan, context: scope ? { ...context, scope } : context })
@@ -104,10 +118,44 @@ export function executePlan({ plan, context }) {
104
118
  return executeLimit(plan, context)
105
119
  } else if (plan.type === 'SetOperation') {
106
120
  return executeSetOperation(plan, context)
121
+ } else if (plan.type === 'TableFunction') {
122
+ return executeTableFunction(plan, context)
107
123
  }
108
124
  return { columns: [], async *rows() {} }
109
125
  }
110
126
 
127
+ /**
128
+ * Executes a table-valued function (e.g. UNNEST).
129
+ * Evaluates the argument once against an empty row and yields one row per
130
+ * element of the resulting array. Null or non-array input yields zero rows.
131
+ *
132
+ * @param {TableFunctionNode} plan
133
+ * @param {ExecuteContext} context
134
+ * @returns {QueryResults}
135
+ */
136
+ function executeTableFunction(plan, context) {
137
+ if (plan.funcName !== 'UNNEST') {
138
+ throw new Error(`Unsupported table function: ${plan.funcName}`)
139
+ }
140
+ const columns = [plan.columnName]
141
+ return {
142
+ columns,
143
+ async *rows() {
144
+ /** @type {AsyncRow} */
145
+ const row = context.outerRow ?? { columns: [], cells: {} }
146
+ const value = await evaluateExpr({ node: plan.args[0], row, rowIndex: 1, context })
147
+ if (!Array.isArray(value)) return
148
+ for (const element of value) {
149
+ if (context.signal?.aborted) return
150
+ yield {
151
+ columns,
152
+ cells: { [plan.columnName]: () => Promise.resolve(element) },
153
+ }
154
+ }
155
+ },
156
+ }
157
+ }
158
+
111
159
  /**
112
160
  * Derives output column names from SELECT columns and available child columns.
113
161
  *
@@ -124,7 +172,7 @@ export function selectColumnNames(selectColumns, childColumns) {
124
172
  for (const key of childColumns) {
125
173
  if (prefix && !key.startsWith(prefix)) continue
126
174
  const dotIndex = key.indexOf('.')
127
- const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
175
+ const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
128
176
  result.push(outputKey)
129
177
  }
130
178
  } else {
@@ -217,33 +265,30 @@ function executeScan(plan, context) {
217
265
  function executeCount(plan, context) {
218
266
  const { tables, signal } = context
219
267
  const table = validateTable({ ...plan, tables })
268
+ const columns = plan.columns.map(col => col.alias ?? derivedAlias(col.expr))
220
269
 
221
270
  return {
222
- columns: plan.columns.map(col => col.alias ?? derivedAlias(col.expr)),
271
+ columns,
223
272
  numRows: 1,
224
273
  maxRows: 1,
225
274
  async *rows() {
226
275
  // Use source numRows if available
227
- let count = table.numRows
228
- if (count === undefined) {
276
+ const countPromise = table.numRows !== undefined ? Promise.resolve(table.numRows) : (async () => {
229
277
  // Fall back to counting rows via scan
230
- count = 0
278
+ let count = 0
231
279
  const { rows } = table.scan({ signal })
232
280
  // eslint-disable-next-line no-unused-vars
233
281
  for await (const _ of rows()) {
234
282
  if (signal?.aborted) return
235
283
  count++
236
284
  }
237
- }
285
+ return count
286
+ })()
238
287
 
239
- /** @type {string[]} */
240
- const columns = []
241
288
  /** @type {AsyncCells} */
242
289
  const cells = {}
243
- for (const col of plan.columns) {
244
- const alias = col.alias ?? derivedAlias(col.expr)
245
- columns.push(alias)
246
- cells[alias] = () => Promise.resolve(count)
290
+ for (const alias of columns) {
291
+ cells[alias] = () => countPromise
247
292
  }
248
293
  yield { columns, cells }
249
294
  },
@@ -318,21 +363,19 @@ async function* filterRows(rows, condition, context, limit) {
318
363
  * @param {AbortSignal} [signal]
319
364
  * @yields {AsyncRow}
320
365
  */
321
- async function* limitRows(rows, limit, offset, signal) {
322
- const skip = offset ?? 0
323
- const max = limit ?? Infinity
324
- if (max <= 0) return
366
+ async function* limitRows(rows, limit = Infinity, offset = 0, signal) {
367
+ if (limit <= 0) return
325
368
  let skipped = 0
326
369
  let yielded = 0
327
370
  for await (const row of rows) {
328
371
  if (signal?.aborted) return
329
- if (skipped < skip) {
372
+ if (skipped < offset) {
330
373
  skipped++
331
374
  continue
332
375
  }
333
376
  yield row
334
377
  yielded++
335
- if (yielded >= max) return
378
+ if (yielded >= limit) return
336
379
  }
337
380
  }
338
381
 
@@ -361,86 +404,57 @@ function executeFilter(plan, context) {
361
404
  */
362
405
  function executeProject(plan, context) {
363
406
  const child = executePlan({ plan: plan.child, context })
407
+ const columns = selectColumnNames(plan.columns, child.columns)
364
408
 
365
- // Pre-compute column names for derived columns (avoids per-row derivedAlias calls)
366
- const hasStar = plan.columns.some(col => col.type === 'star')
367
-
368
- /** @type {string[] | undefined} */
369
- let staticColumns
370
- /** @type {{ alias: string, sourceName: string }[] | undefined} */
371
- let identifierMap
372
- if (!hasStar) {
373
- const derived = /** @type {DerivedColumn[]} */ (plan.columns)
374
- staticColumns = derived.map(col => col.alias ?? derivedAlias(col.expr))
375
- const allIdentifiers = derived.every(col =>
376
- col.expr.type === 'identifier' && !col.expr.prefix
377
- )
378
- if (allIdentifiers) {
379
- identifierMap = derived.map((col, i) => ({
380
- alias: staticColumns[i],
381
- sourceName: /** @type {IdentifierNode} */ (col.expr).name,
382
- }))
383
- }
384
- }
409
+ const resolveable = plan.columns.every(col =>
410
+ col.type === 'star' || col.type === 'derived' && col.expr.type === 'identifier'
411
+ )
385
412
 
386
413
  return {
387
- columns: selectColumnNames(plan.columns, child.columns),
414
+ columns,
388
415
  numRows: child.numRows,
389
416
  maxRows: child.maxRows,
390
417
  async *rows() {
391
418
  let rowIndex = 0
392
- let identifierMapValidated = false
393
419
 
394
420
  for await (const row of child.rows()) {
395
421
  if (context.signal?.aborted) return
396
422
  rowIndex++
397
-
398
- // Validate identifier fast path on first row (may fail for JOINs with prefixed columns)
399
- if (identifierMap && !identifierMapValidated) {
400
- identifierMapValidated = true
401
- if (!identifierMap.every(m => m.sourceName in row.cells)) {
402
- identifierMap = undefined
403
- }
404
- }
405
-
406
- // Fast path: all columns are simple identifier references
407
- if (identifierMap) {
408
- /** @type {AsyncCells} */
409
- const cells = {}
410
- const source = row.resolved
411
- /** @type {Record<string, SqlPrimitive> | undefined} */
412
- const resolved = source ? {} : undefined
413
- for (const { alias, sourceName } of identifierMap) {
414
- cells[alias] = row.cells[sourceName]
415
- if (resolved && source) resolved[alias] = source[sourceName]
416
- }
417
- yield resolved
418
- ? { columns: staticColumns, cells, resolved }
419
- : { columns: staticColumns, cells }
420
- continue
421
- }
422
-
423
423
  const currentRowIndex = rowIndex
424
424
 
425
- /** @type {string[]} */
426
- const columns = staticColumns ?? []
427
425
  /** @type {AsyncCells} */
428
426
  const cells = {}
429
-
430
- for (let i = 0; i < plan.columns.length; i++) {
431
- const col = plan.columns[i]
427
+ // Only safe to propagate resolved when every output column comes from
428
+ // the star branch derived expressions evaluate lazily and can't be
429
+ // pre-materialized here, and a partial resolved would make
430
+ // collect()/downstream identifier fast paths read undefined.
431
+ const source = resolveable ? row.resolved : undefined
432
+ /** @type {Record<string, SqlPrimitive> | undefined} */
433
+ const resolved = source ? {} : undefined
434
+
435
+ let colIdx = 0
436
+ for (const col of plan.columns) {
432
437
  if (col.type === 'star') {
433
438
  const prefix = col.table ? `${col.table}.` : undefined
434
439
  for (const key of row.columns) {
435
440
  if (prefix && !key.startsWith(prefix)) continue
436
441
  const dotIndex = key.indexOf('.')
437
- const outputKey = prefix ? key.substring(prefix.length) : dotIndex >= 0 ? key.substring(dotIndex + 1) : key
438
- columns.push(outputKey)
442
+ const outputKey = dotIndex >= 0 ? key.substring(dotIndex + 1) : key
439
443
  cells[outputKey] = row.cells[key]
444
+ if (resolved && source) resolved[outputKey] = source[key]
445
+ colIdx++
440
446
  }
447
+ } else if (col.expr.type === 'identifier') {
448
+ // Common case: simple identifier. Avoid evaluateExpr overhead by
449
+ // directly mapping to the child's cell, relying on the planner to
450
+ // have normalized the identifier to match the child's column layout.
451
+ const id = col.expr
452
+ const sourceName = id.prefix ? `${id.prefix}.${id.name}` : id.name
453
+ cells[columns[colIdx]] = row.cells[sourceName]
454
+ if (resolved && source) resolved[columns[colIdx]] = source[sourceName]
455
+ colIdx++
441
456
  } else {
442
- const alias = staticColumns ? staticColumns[i] : (col.alias ?? derivedAlias(col.expr))
443
- if (!staticColumns) columns.push(alias)
457
+ const alias = columns[colIdx++]
444
458
  cells[alias] = () => evaluateExpr({
445
459
  node: col.expr,
446
460
  row,
@@ -450,7 +464,7 @@ function executeProject(plan, context) {
450
464
  }
451
465
  }
452
466
 
453
- yield { columns, cells }
467
+ yield { columns, cells, resolved }
454
468
  }
455
469
  },
456
470
  }
@@ -15,6 +15,9 @@ import { executePlan } from './execute.js'
15
15
  * @returns {QueryResults}
16
16
  */
17
17
  export function executeNestedLoopJoin(plan, context) {
18
+ if (plan.lateral) {
19
+ return executeLateralJoin(plan, context)
20
+ }
18
21
  const left = executePlan({ plan: plan.left, context })
19
22
  const right = executePlan({ plan: plan.right, context })
20
23
  return {
@@ -81,6 +84,57 @@ export function executeNestedLoopJoin(plan, context) {
81
84
  }
82
85
  }
83
86
 
87
+ /**
88
+ * Executes a LATERAL nested loop join — the right side is re-executed per
89
+ * left row with the left row available as `context.outerRow`.
90
+ *
91
+ * @param {NestedLoopJoinNode} plan
92
+ * @param {ExecuteContext} context
93
+ * @returns {QueryResults}
94
+ */
95
+ function executeLateralJoin(plan, context) {
96
+ const left = executePlan({ plan: plan.left, context })
97
+ // Right columns are known statically for table functions (the common case).
98
+ const rightCols = plan.right.type === 'TableFunction' ? [plan.right.columnName] : []
99
+ return {
100
+ columns: mergeColumnNames(left.columns, rightCols, plan.leftAlias, plan.rightAlias),
101
+ async *rows() {
102
+ const leftTable = plan.leftAlias
103
+ const rightTable = plan.rightAlias
104
+
105
+ for await (const leftRow of left.rows()) {
106
+ if (context.signal?.aborted) return
107
+
108
+ // When nested inside a correlated subquery, preserve the enclosing
109
+ // outer row so UNNEST args can reference its columns (e.g. o.arr).
110
+ const nestedOuter = context.outerRow
111
+ ? mergeOuterRows(context.outerRow, leftRow, leftTable)
112
+ : leftRow
113
+ const subContext = { ...context, outerRow: nestedOuter }
114
+ const right = executePlan({ plan: plan.right, context: subContext })
115
+
116
+ let hasMatch = false
117
+ for await (const rightRow of right.rows()) {
118
+ if (context.signal?.aborted) return
119
+ const merged = mergeRows(leftRow, rightRow, leftTable, rightTable)
120
+ const matches = plan.condition === undefined
121
+ ? true
122
+ : await evaluateExpr({ node: plan.condition, row: merged, context })
123
+ if (matches) {
124
+ hasMatch = true
125
+ yield merged
126
+ }
127
+ }
128
+
129
+ if (!hasMatch && plan.joinType === 'LEFT') {
130
+ const nullRight = createNullRow(rightCols)
131
+ yield mergeRows(leftRow, nullRight, leftTable, rightTable)
132
+ }
133
+ }
134
+ },
135
+ }
136
+ }
137
+
84
138
  /**
85
139
  * Executes a positional join operation
86
140
  *
@@ -221,6 +275,28 @@ export function executeHashJoin(plan, context) {
221
275
  }
222
276
  }
223
277
 
278
+ /**
279
+ * Merges an enclosing correlated outer row with a lateral join's left row.
280
+ * Outer cells are kept as-is; left cells are added under a qualified alias
281
+ * so qualified refs on either side resolve unambiguously.
282
+ *
283
+ * @param {AsyncRow} outerRow
284
+ * @param {AsyncRow} leftRow
285
+ * @param {string} leftTable
286
+ * @returns {AsyncRow}
287
+ */
288
+ function mergeOuterRows(outerRow, leftRow, leftTable) {
289
+ const columns = [...outerRow.columns]
290
+ /** @type {AsyncCells} */
291
+ const cells = { ...outerRow.cells }
292
+ for (const [key, cell] of Object.entries(leftRow.cells)) {
293
+ const alias = key.includes('.') ? key : `${leftTable}.${key}`
294
+ if (!(alias in cells)) columns.push(alias)
295
+ cells[alias] = cell
296
+ }
297
+ return { columns, cells }
298
+ }
299
+
224
300
  /**
225
301
  * Creates a NULL-filled row with the given column names
226
302
  *
@@ -1,3 +1,4 @@
1
+ import { derivedAlias } from '../expression/alias.js'
1
2
  import { evaluateExpr } from '../expression/evaluate.js'
2
3
  import { executePlan } from './execute.js'
3
4
  import { compareForTerm } from './utils.js'
@@ -7,6 +8,8 @@ import { compareForTerm } from './utils.js'
7
8
  * @import { SortNode } from '../plan/types.js'
8
9
  */
9
10
 
11
+ const MAX_CHUNK = 256
12
+
10
13
  /**
11
14
  * Executes a sort operation (ORDER BY)
12
15
  *
@@ -49,15 +52,34 @@ export function executeSort(plan, context) {
49
52
  continue
50
53
  }
51
54
 
52
- // Evaluate this column for all rows in the group
55
+ // Evaluate this column for all rows in the group, in parallel
56
+ // chunks that double up to MAX_CHUNK so a slow UDF doesn't serialize.
57
+ // Cache each value back into the row so downstream projection can
58
+ // reuse it instead of re-invoking the expression.
59
+ const alias = derivedAlias(term.expr)
60
+ /** @type {number[]} */
61
+ const missing = []
53
62
  for (const idx of group) {
54
- if (evaluatedValues[idx][orderByIdx] === undefined) {
55
- evaluatedValues[idx][orderByIdx] = await evaluateExpr({
56
- node: term.expr,
57
- row: rows[idx],
58
- context,
59
- })
63
+ if (evaluatedValues[idx][orderByIdx] === undefined) missing.push(idx)
64
+ }
65
+ let chunkSize = 1
66
+ let start = 0
67
+ while (start < missing.length) {
68
+ if (context.signal?.aborted) return
69
+ const chunk = missing.slice(start, start + chunkSize)
70
+ const values = await Promise.all(chunk.map(idx =>
71
+ evaluateExpr({ node: term.expr, row: rows[idx], context })
72
+ ))
73
+ for (let i = 0; i < chunk.length; i++) {
74
+ const idx = chunk[i]
75
+ const value = values[i]
76
+ evaluatedValues[idx][orderByIdx] = value
77
+ if (!(alias in rows[idx].cells)) {
78
+ rows[idx].cells[alias] = () => Promise.resolve(value)
79
+ }
60
80
  }
81
+ start += chunk.length
82
+ chunkSize = Math.min(chunkSize * 2, MAX_CHUNK)
61
83
  }
62
84
 
63
85
  // Sort the group by this column
@@ -119,6 +119,18 @@ export async function evaluateExpr({ node, row, rowIndex, rows, context }) {
119
119
  if (node.type === 'function') {
120
120
  const funcName = node.funcName.toUpperCase()
121
121
 
122
+ // Reuse a previously cached evaluation of this expression, written back
123
+ // as a synthetic cell (e.g. by executeSort). Cached cells are not added to
124
+ // row.columns, so checking that the alias is NOT a real column guards
125
+ // against false positives where a table column happens to share a name
126
+ // with the expression's derived alias.
127
+ if (!rows) {
128
+ const alias = derivedAlias(node)
129
+ if (alias in row.cells && !row.columns.includes(alias)) {
130
+ return row.cells[alias]()
131
+ }
132
+ }
133
+
122
134
  // Handle aggregate functions
123
135
  if (isAggregateFunc(funcName)) {
124
136
  if (!rows) {
@@ -1,6 +1,7 @@
1
1
  import { expectNoAggregate } from '../validation/aggregates.js'
2
+ import { ParseError } from '../validation/parseErrors.js'
2
3
  import { parseExpression } from './expression.js'
3
- import { parseTableAlias } from './parse.js'
4
+ import { isTableFunctionStart, parseFromFunction, parseTableAlias } from './parse.js'
4
5
  import { current, expect, match } from './state.js'
5
6
 
6
7
  /**
@@ -18,6 +19,27 @@ export function parseJoins(state) {
18
19
  while (true) {
19
20
  const tok = current(state)
20
21
 
22
+ // Comma-join: implicit CROSS JOIN LATERAL, currently only for table functions.
23
+ if (match(state, 'comma')) {
24
+ if (!isTableFunctionStart(state)) {
25
+ throw new ParseError({
26
+ message: 'Comma-separated FROM is only supported with table functions like UNNEST; use explicit JOIN ... ON ... for regular tables',
27
+ positionStart: tok.positionStart,
28
+ positionEnd: state.lastPos,
29
+ })
30
+ }
31
+ const fromFunction = parseFromFunction(state)
32
+ joins.push({
33
+ joinType: 'CROSS',
34
+ table: fromFunction.funcName,
35
+ alias: fromFunction.alias,
36
+ fromFunction,
37
+ positionStart: tok.positionStart,
38
+ positionEnd: state.lastPos,
39
+ })
40
+ continue
41
+ }
42
+
21
43
  // Check for join keywords
22
44
  /** @type {JoinType} */
23
45
  let joinType = 'INNER'
@@ -35,6 +57,8 @@ export function parseJoins(state) {
35
57
  joinType = 'FULL'
36
58
  } else if (match(state, 'keyword', 'POSITIONAL')) {
37
59
  joinType = 'POSITIONAL'
60
+ } else if (match(state, 'keyword', 'CROSS')) {
61
+ joinType = 'CROSS'
38
62
  } else if (!match(state, 'keyword', 'JOIN')) {
39
63
  // Not a join keyword, stop parsing joins
40
64
  break
@@ -45,6 +69,64 @@ export function parseJoins(state) {
45
69
  expect(state, 'keyword', 'JOIN')
46
70
  }
47
71
 
72
+ // Optional LATERAL keyword; table functions are implicitly LATERAL.
73
+ const lateralTok = current(state)
74
+ const hasLateral = match(state, 'keyword', 'LATERAL')
75
+
76
+ // Table function on the right side (e.g. JOIN UNNEST(t.arr) AS u(x))
77
+ if (isTableFunctionStart(state)) {
78
+ if (joinType === 'POSITIONAL') {
79
+ throw new ParseError({
80
+ message: 'POSITIONAL JOIN does not support table functions',
81
+ positionStart: tok.positionStart,
82
+ positionEnd: state.lastPos,
83
+ })
84
+ }
85
+ if (joinType === 'RIGHT' || joinType === 'FULL') {
86
+ throw new ParseError({
87
+ message: `${joinType} JOIN not supported with table functions — right side depends on left row`,
88
+ positionStart: tok.positionStart,
89
+ positionEnd: state.lastPos,
90
+ })
91
+ }
92
+ const fromFunction = parseFromFunction(state)
93
+
94
+ /** @type {ExprNode | undefined} */
95
+ let condition
96
+ if (joinType !== 'CROSS') {
97
+ expect(state, 'keyword', 'ON')
98
+ condition = parseExpression(state)
99
+ expectNoAggregate(condition, 'JOIN ON')
100
+ }
101
+
102
+ joins.push({
103
+ joinType,
104
+ table: fromFunction.funcName,
105
+ alias: fromFunction.alias,
106
+ on: condition,
107
+ fromFunction,
108
+ positionStart: tok.positionStart,
109
+ positionEnd: state.lastPos,
110
+ })
111
+ continue
112
+ }
113
+
114
+ if (hasLateral) {
115
+ throw new ParseError({
116
+ message: 'LATERAL is only supported with table functions',
117
+ positionStart: lateralTok.positionStart,
118
+ positionEnd: lateralTok.positionEnd,
119
+ })
120
+ }
121
+
122
+ if (joinType === 'CROSS') {
123
+ throw new ParseError({
124
+ message: 'CROSS JOIN is currently supported only with table functions like UNNEST',
125
+ positionStart: tok.positionStart,
126
+ positionEnd: state.lastPos,
127
+ })
128
+ }
129
+
48
130
  // Parse table name and optional alias
49
131
  const tableTok = expect(state, 'identifier')
50
132
  const tableAlias = parseTableAlias(state)