squirreling 0.12.23 → 0.12.25

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
@@ -12,6 +12,8 @@
12
12
 
13
13
  Squirreling is a streaming async SQL engine in pure JavaScript. Built for the browser from the ground up: streaming input and output, pluggable data sources, and lazy async cell evaluation. This makes Squirreling ideal for querying data from network sources, APIs, or LLMs where latency and cost matter.
14
14
 
15
+ > Part of **[HypStack](https://hypstack.ai/)**, an open-source stack for AI observability.
16
+
15
17
  - **Standard SQL**: Full SQL support for querying data (read-only)
16
18
  - **Async UDFs**: User-defined functions can call APIs or models
17
19
  - **Tiny**: 13 kb bundle, zero dependencies, instant startup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squirreling",
3
- "version": "0.12.23",
3
+ "version": "0.12.25",
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": "26.0.1",
43
+ "@vitest/coverage-v8": "4.1.9",
44
44
  "eslint": "9.39.4",
45
- "eslint-plugin-jsdoc": "63.0.0",
45
+ "eslint-plugin-jsdoc": "63.0.10",
46
46
  "typescript": "6.0.3",
47
- "vitest": "4.1.7"
47
+ "vitest": "4.1.9"
48
48
  }
49
49
  }
package/src/ast.d.ts CHANGED
@@ -212,6 +212,7 @@ export interface JoinClause extends AstBase {
212
212
  on?: ExprNode
213
213
  using?: string[]
214
214
  fromFunction?: FromFunction
215
+ subquery?: FromSubquery
215
216
  }
216
217
 
217
218
  // All AST node derive from this base, which includes position info for error reporting and other purposes
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { AsyncCells, AsyncDataSource, AsyncRow, ScanColumnOptions, SqlPrimitive } from '../types.js'
2
+ * @import { AsyncCells, AsyncDataSource, AsyncRow, SqlPrimitive } from '../types.js'
3
3
  */
4
4
 
5
5
  /**
@@ -71,13 +71,15 @@ export function memorySource({ data, columns }) {
71
71
  }
72
72
 
73
73
  /**
74
- * Wraps a data source that caches all accessed rows in memory
74
+ * Wraps a data source, memoizing accessed cells. The row cache is a WeakMap
75
+ * keyed on row identity so entries are collectible once the row is unreachable,
76
+ * keeping a streaming scan O(1) instead of O(rows).
75
77
  * @param {AsyncDataSource} source
76
78
  * @returns {AsyncDataSource}
77
79
  */
78
80
  export function cachedDataSource(source) {
79
- /** @type {Map<string, Promise<SqlPrimitive>>} */
80
- const cache = new Map()
81
+ /** @type {WeakMap<object, Map<string, Promise<SqlPrimitive>>>} */
82
+ const cache = new WeakMap()
81
83
  return {
82
84
  ...source,
83
85
  scan(options) {
@@ -90,68 +92,38 @@ export function cachedDataSource(source) {
90
92
  return { rows, appliedWhere, appliedLimitOffset }
91
93
  }
92
94
 
93
- // Adjust index when source applied offset so cache keys match original rows
94
- const indexOffset = appliedLimitOffset && options.offset ? options.offset : 0
95
-
96
95
  return {
97
96
  async *rows() {
98
- let index = 0
99
97
  for await (const row of rows()) {
100
98
  if (options.signal?.aborted) break
101
- const rowIndex = index + indexOffset
99
+ const anchor = row.resolved ?? row
100
+ let rowCache = cache.get(anchor)
101
+ if (!rowCache) {
102
+ rowCache = new Map()
103
+ cache.set(anchor, rowCache)
104
+ }
102
105
  /** @type {AsyncCells} */
103
106
  const cells = {}
104
107
  for (const key of row.columns) {
105
108
  const cell = row.cells[key]
106
- // Wrap the cell to cache accesses
107
109
  cells[key] = () => {
108
- const cacheKey = `${rowIndex}:${key}`
109
- let value = cache.get(cacheKey)
110
+ let value = rowCache.get(key)
110
111
  if (!value) {
111
112
  value = cell()
112
- cache.set(cacheKey, value)
113
+ rowCache.set(key, value)
113
114
  }
114
115
  return value
115
116
  }
116
117
  }
117
- yield { columns: row.columns, cells }
118
- index++
118
+ // Preserve resolved so downstream fast paths still apply.
119
+ yield { columns: row.columns, cells, resolved: row.resolved }
119
120
  }
120
121
  },
121
122
  appliedWhere,
122
123
  appliedLimitOffset,
123
124
  }
124
125
  },
125
- ...source.scanColumn && {
126
- /**
127
- * @param {ScanColumnOptions} options
128
- * @returns {AsyncIterable<ArrayLike<SqlPrimitive>>}
129
- */
130
- scanColumn(options) {
131
- const inner = source.scanColumn(options)
132
- const indexOffset = options.offset ?? 0
133
- return (async function* () {
134
- let chunkStart = 0
135
- for await (const chunk of inner) {
136
- if (options.signal?.aborted) break
137
- /** @type {SqlPrimitive[]} */
138
- const cached = new Array(chunk.length)
139
- for (let i = 0; i < chunk.length; i++) {
140
- const cacheKey = `${chunkStart + i + indexOffset}:${options.column}`
141
- const existing = cache.get(cacheKey)
142
- if (existing) {
143
- cached[i] = await existing
144
- } else {
145
- const value = chunk[i]
146
- cache.set(cacheKey, Promise.resolve(value))
147
- cached[i] = value
148
- }
149
- }
150
- yield cached
151
- chunkStart += chunk.length
152
- }
153
- })()
154
- },
155
- },
126
+ // scanColumn passes through from ...source unwrapped: column values are
127
+ // already materialized, so caching them only retained memory unboundedly.
156
128
  }
157
129
  }
@@ -95,6 +95,7 @@ export function executeHashAggregate(plan, context) {
95
95
  }
96
96
  allRows.push(row)
97
97
  }
98
+ context.signal?.throwIfAborted()
98
99
 
99
100
  // Group rows by GROUP BY keys.
100
101
  // Each chunk dispatches all per-row key evaluations in parallel so
@@ -211,6 +212,7 @@ export function executeScalarAggregate(plan, context) {
211
212
  }
212
213
  group.push(row)
213
214
  }
215
+ context.signal?.throwIfAborted()
214
216
 
215
217
  const asyncRow = projectAggregateColumns(plan.columns, group, context)
216
218
 
@@ -282,9 +284,36 @@ function tryColumnScanAggregate(plan, { tables, signal }) {
282
284
  /** @type {AsyncCells} */
283
285
  const cells = {}
284
286
 
287
+ // Group specs by column so each column is scanned at most once no matter how
288
+ // many aggregates read it (e.g. MIN(x), MAX(x), AVG(x) share one pass).
289
+ /** @type {Map<string, ColumnAggSpec[]>} */
290
+ const specsByColumn = new Map()
291
+ for (const spec of specs) {
292
+ const group = specsByColumn.get(spec.column)
293
+ if (group) group.push(spec)
294
+ else specsByColumn.set(spec.column, [spec])
295
+ }
296
+
297
+ // Each column's single pass is computed once and shared by all its cells;
298
+ // a column is only scanned if one of its aggregates is actually read.
299
+ /** @type {Map<string, Promise<Map<string, SqlPrimitive>>>} */
300
+ const passes = new Map()
301
+ /**
302
+ * @param {string} column
303
+ * @returns {Promise<Map<string, SqlPrimitive>>}
304
+ */
305
+ function scanOnce(column) {
306
+ let pass = passes.get(column)
307
+ if (!pass) {
308
+ pass = scanColumnGroup({ table, specs: specsByColumn.get(column) ?? [], limit, offset, signal })
309
+ passes.set(column, pass)
310
+ }
311
+ return pass
312
+ }
313
+
285
314
  for (const spec of specs) {
286
315
  columns.push(spec.alias)
287
- cells[spec.alias] = () => scanColumnAggregate({ table, spec, limit, offset, signal })
316
+ cells[spec.alias] = async () => (await scanOnce(spec.column)).get(spec.alias) ?? null
288
317
  }
289
318
 
290
319
  yield { columns, cells }
@@ -316,68 +345,93 @@ function extractColumnAggSpec({ expr, alias }) {
316
345
  }
317
346
 
318
347
  /**
319
- * Scans a single column and computes an aggregate value.
348
+ * @typedef {{
349
+ * spec: ColumnAggSpec,
350
+ * count: number,
351
+ * sum: number,
352
+ * min: SqlPrimitive,
353
+ * max: SqlPrimitive,
354
+ * seen: Set<unknown> | null,
355
+ * }} AggAccumulator
356
+ */
357
+
358
+ /**
359
+ * Scans a column once and computes every aggregate over it in a single pass.
360
+ * All specs share the one scanColumn walk, so MIN(x)/MAX(x)/AVG(x) decode x once.
320
361
  *
321
362
  * @param {Object} options
322
363
  * @param {AsyncDataSource} options.table
323
- * @param {ColumnAggSpec} options.spec
364
+ * @param {ColumnAggSpec[]} options.specs - aggregates over the same column
324
365
  * @param {number} [options.limit]
325
366
  * @param {number} [options.offset]
326
367
  * @param {AbortSignal} [options.signal]
327
- * @returns {Promise<SqlPrimitive>}
368
+ * @returns {Promise<Map<string, SqlPrimitive>>} alias → aggregate value
328
369
  */
329
- async function scanColumnAggregate({ table, spec, limit, offset, signal }) {
330
- const values = table.scanColumn({ column: spec.column, limit, offset, signal })
331
-
332
- if (spec.funcName === 'COUNT' && spec.distinct) {
333
- const seen = new Set()
334
- for await (const chunk of values) {
335
- if (signal?.aborted) return
336
- for (let i = 0; i < chunk.length; i++) {
337
- const v = chunk[i]
338
- if (v == null) continue
339
- seen.add(keyify(v))
340
- }
341
- }
342
- return seen.size
343
- }
344
-
345
- if (spec.funcName === 'COUNT') {
346
- let count = 0
347
- for await (const chunk of values) {
348
- if (signal?.aborted) return
349
- for (let i = 0; i < chunk.length; i++) {
350
- if (chunk[i] != null) count++
351
- }
352
- }
353
- return count
354
- }
355
-
356
- // SUM, AVG, MIN, MAX
357
- let sum = 0
358
- let count = 0
359
- /** @type {SqlPrimitive} */
360
- let min = null
361
- /** @type {SqlPrimitive} */
362
- let max = null
370
+ async function scanColumnGroup({ table, specs, limit, offset, signal }) {
371
+ const { column } = specs[0]
372
+ const values = table.scanColumn({ column, limit, offset, signal })
373
+
374
+ /** @type {AggAccumulator[]} */
375
+ const accs = specs.map(spec => /** @type {AggAccumulator} */ ({
376
+ spec,
377
+ count: 0,
378
+ sum: 0,
379
+ min: null,
380
+ max: null,
381
+ seen: spec.funcName === 'COUNT' && spec.distinct ? new Set() : null,
382
+ }))
363
383
 
364
384
  for await (const chunk of values) {
365
- if (signal?.aborted) return
385
+ signal?.throwIfAborted()
366
386
  for (let i = 0; i < chunk.length; i++) {
367
387
  const v = chunk[i]
368
388
  if (v == null) continue
369
- if (min === null || v < min) min = v
370
- if (max === null || v > max) max = v
371
- const num = Number(v)
372
- if (!Number.isFinite(num)) continue
373
- sum += num
374
- count++
389
+ for (const acc of accs) {
390
+ switch (acc.spec.funcName) {
391
+ case 'COUNT':
392
+ if (acc.seen) acc.seen.add(keyify(v))
393
+ else acc.count++
394
+ break
395
+ case 'MIN':
396
+ if (acc.min === null || v < acc.min) acc.min = v
397
+ break
398
+ case 'MAX':
399
+ if (acc.max === null || v > acc.max) acc.max = v
400
+ break
401
+ default: { // SUM, AVG
402
+ const num = Number(v)
403
+ if (Number.isFinite(num)) {
404
+ acc.sum += num
405
+ acc.count++
406
+ }
407
+ }
408
+ }
409
+ }
375
410
  }
376
411
  }
412
+ signal?.throwIfAborted()
377
413
 
378
- if (spec.funcName === 'SUM') return count === 0 ? null : sum
379
- if (spec.funcName === 'AVG') return count === 0 ? null : sum / count
380
- if (spec.funcName === 'MIN') return min
381
- if (spec.funcName === 'MAX') return max
382
- return null
414
+ /** @type {Map<string, SqlPrimitive>} */
415
+ const result = new Map()
416
+ for (const acc of accs) {
417
+ result.set(acc.spec.alias, finalizeAgg(acc))
418
+ }
419
+ return result
420
+ }
421
+
422
+ /**
423
+ * Reduces one accumulator to its final aggregate value.
424
+ *
425
+ * @param {AggAccumulator} acc
426
+ * @returns {SqlPrimitive}
427
+ */
428
+ function finalizeAgg(acc) {
429
+ switch (acc.spec.funcName) {
430
+ case 'COUNT': return acc.seen ? acc.seen.size : acc.count
431
+ case 'SUM': return acc.count === 0 ? null : acc.sum
432
+ case 'AVG': return acc.count === 0 ? null : acc.sum / acc.count
433
+ case 'MIN': return acc.min
434
+ case 'MAX': return acc.max
435
+ default: return null
436
+ }
383
437
  }
@@ -356,16 +356,20 @@ function executeCount(plan, context) {
356
356
  numRows: 1,
357
357
  maxRows: 1,
358
358
  async *rows() {
359
- // Use source numRows if available
360
- const countPromise = table.numRows !== undefined ? Promise.resolve(table.numRows) : (async () => {
359
+ const countPromise = (async () => {
360
+ signal?.throwIfAborted()
361
+ // Use source numRows if available
362
+ if (table.numRows !== undefined) return table.numRows
363
+
361
364
  // Fall back to counting rows via scan
362
365
  let count = 0
363
366
  const { rows } = table.scan({ signal })
364
367
  // eslint-disable-next-line no-unused-vars
365
368
  for await (const _ of rows()) {
366
- if (signal?.aborted) return
369
+ signal?.throwIfAborted()
367
370
  count++
368
371
  }
372
+ signal?.throwIfAborted()
369
373
  return count
370
374
  })()
371
375
 
@@ -47,7 +47,7 @@ export function executeNestedLoopJoin(plan, context) {
47
47
 
48
48
  let innerCount = 0
49
49
  for await (const leftRow of left.rows()) {
50
- if (context.signal?.aborted) break
50
+ if (context.signal?.aborted) return
51
51
 
52
52
  if (!leftCols) {
53
53
  leftCols = leftRow.columns
@@ -80,6 +80,8 @@ export function executeNestedLoopJoin(plan, context) {
80
80
  }
81
81
  }
82
82
 
83
+ if (context.signal?.aborted) return
84
+
83
85
  // Unmatched right rows for RIGHT/FULL joins
84
86
  if (matchedRightRows) {
85
87
  for (const rightRow of rightRows) {
@@ -248,7 +250,7 @@ export function executeHashJoin(plan, context) {
248
250
  // Probe phase: stream left rows
249
251
  let innerCount = 0
250
252
  for await (const leftRow of left.rows()) {
251
- if (context.signal?.aborted) break
253
+ if (context.signal?.aborted) return
252
254
 
253
255
  if (!leftCols) {
254
256
  leftCols = leftRow.columns
@@ -285,6 +287,8 @@ export function executeHashJoin(plan, context) {
285
287
  }
286
288
  }
287
289
 
290
+ if (context.signal?.aborted) return
291
+
288
292
  // Unmatched right rows for RIGHT/FULL joins
289
293
  if (matchedRightRows) {
290
294
  for (const rightRow of rightRows) {
@@ -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
 
@@ -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,9 +218,42 @@ 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
258
  // Parse ON condition or USING column list (not for POSITIONAL joins)
226
259
  /** @type {ExprNode | undefined} */
@@ -246,12 +279,13 @@ export function parseJoins(state) {
246
279
 
247
280
  joins.push({
248
281
  joinType,
249
- table: tableTok.value,
282
+ table: tableName,
250
283
  alias: tableAlias,
251
284
  on: condition,
252
285
  using,
286
+ subquery,
253
287
  positionStart: tok.positionStart,
254
- positionEnd: tableTok.positionEnd,
288
+ positionEnd: endPos,
255
289
  })
256
290
  }
257
291
 
@@ -420,6 +420,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
420
420
  for (const col of tableFunctionColumnNames(join.fromFunction)) {
421
421
  result.push(`${joinAlias}.${col}`)
422
422
  }
423
+ } else if (join.subquery) {
424
+ for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
425
+ result.push(`${joinAlias}.${col}`)
426
+ }
423
427
  } else {
424
428
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
425
429
  result.push(`${joinAlias}.${col}`)
@@ -446,6 +450,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
446
450
  for (const col of tableFunctionColumnNames(join.fromFunction)) {
447
451
  result.push(`${joinAlias}.${col}`)
448
452
  }
453
+ } else if (join.subquery) {
454
+ for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
455
+ result.push(`${joinAlias}.${col}`)
456
+ }
449
457
  } else {
450
458
  for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
451
459
  result.push(`${joinAlias}.${col}`)
package/src/plan/plan.js CHANGED
@@ -447,18 +447,47 @@ 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 }