Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/ast.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export interface JoinClause extends AstBase {
on?: ExprNode
using?: string[]
fromFunction?: FromFunction
subquery?: FromSubquery
}

// All AST node derive from this base, which includes position info for error reporting and other purposes
Expand Down
2 changes: 2 additions & 0 deletions src/parse/extractTables.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ function walkStatement(stmt, cteScope, refs) {
for (const j of stmt.joins) {
if (j.fromFunction) {
for (const a of j.fromFunction.args) walkExpr(a, cteScope, refs)
} else if (j.subquery) {
walkStatement(j.subquery.query, cteScope, refs)
} else if (!cteScope.has(j.table.toLowerCase())) {
refs.add(j.table)
}
Expand Down
48 changes: 41 additions & 7 deletions src/parse/joins.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { expectNoAggregate } from '../validation/aggregates.js'
import { isTableFunction, validateFunctionArgs } from '../validation/functions.js'
import { ParseError } from '../validation/parseErrors.js'
import { parseExpression } from './expression.js'
import { isTableFunctionStart, parseFromFunction, parseTableAlias, tableFunctionColumnCount, tableFunctionDefaultColumns } from './parse.js'
import { isTableFunctionStart, parseFromFunction, parseStatement, parseTableAlias, tableFunctionColumnCount, tableFunctionDefaultColumns } from './parse.js'
import { consume, current, expect, match } from './state.js'

/**
* @import { ExprNode, FromFunction, JoinClause, JoinType, ParserState } from '../types.js'
* @import { ExprNode, FromFunction, FromSubquery, JoinClause, JoinType, ParserState } from '../types.js'
*/

/**
Expand Down Expand Up @@ -218,9 +218,42 @@ export function parseJoins(state) {
})
}

// Parse table name and optional alias
const tableTok = expect(state, 'identifier')
const tableAlias = parseTableAlias(state)
// Subquery on the right side: JOIN (SELECT ...) AS alias ON ...
const rightTok = current(state)
/** @type {FromSubquery | undefined} */
let subquery
let tableName
/** @type {string | undefined} */
let tableAlias
let endPos
if (rightTok.type === 'paren' && rightTok.value === '(') {
consume(state)
const query = parseStatement(state)
expect(state, 'paren', ')')
tableAlias = parseTableAlias(state)
if (!tableAlias) {
throw new ParseError({
message: 'Subquery in JOIN must have an alias',
positionStart: rightTok.positionStart,
positionEnd: state.lastPos,
})
}
endPos = state.lastPos
subquery = {
type: 'subquery',
query,
alias: tableAlias,
positionStart: rightTok.positionStart,
positionEnd: endPos,
}
tableName = tableAlias
} else {
// Parse table name and optional alias
const tableTok = expect(state, 'identifier')
tableName = tableTok.value
tableAlias = parseTableAlias(state)
endPos = tableTok.positionEnd
}

// Parse ON condition or USING column list (not for POSITIONAL joins)
/** @type {ExprNode | undefined} */
Expand All @@ -246,12 +279,13 @@ export function parseJoins(state) {

joins.push({
joinType,
table: tableTok.value,
table: tableName,
alias: tableAlias,
on: condition,
using,
subquery,
positionStart: tok.positionStart,
positionEnd: tableTok.positionEnd,
positionEnd: endPos,
})
}

Expand Down
8 changes: 8 additions & 0 deletions src/plan/columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
for (const col of tableFunctionColumnNames(join.fromFunction)) {
result.push(`${joinAlias}.${col}`)
}
} else if (join.subquery) {
for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
result.push(`${joinAlias}.${col}`)
}
} else {
for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
result.push(`${joinAlias}.${col}`)
Expand All @@ -446,6 +450,10 @@ export function inferSelectSourceColumns({ select, cteColumns, tables }) {
for (const col of tableFunctionColumnNames(join.fromFunction)) {
result.push(`${joinAlias}.${col}`)
}
} else if (join.subquery) {
for (const col of inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })) {
result.push(`${joinAlias}.${col}`)
}
} else {
for (const col of lookupTableColumns(join.table, cteColumns, tables)) {
result.push(`${joinAlias}.${col}`)
Expand Down
49 changes: 39 additions & 10 deletions src/plan/plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,18 +447,47 @@ function planJoin({ left, joins, leftTable, ctePlans, cteColumns, perTableColumn
continue
}

const ctePlan = ctePlans?.get(join.table.toLowerCase())
/** @type {ScanOptions} */
const rightHints = {}
if (!ctePlan) {
rightHints.columns = perTableColumns.get(rightTable)
validateScan({ ...join, hints: rightHints, tables })
/** @type {QueryPlan} */
let rightScan
if (join.subquery) {
// Subquery on the right side of the join (derived table). Mirror the
// FROM-clause subquery handling: plan the inner statement, push down the
// columns the outer query needs, and wrap in the inner scope so
// correlated subqueries inside resolve against the right aliases.
let subColumns = perTableColumns.get(rightTable)
// Empty array means no columns referenced, but the derived table still
// needs its own columns. Treat empty as unrestricted.
if (subColumns?.length === 0) subColumns = undefined
const subPlan = planStatement({
stmt: join.subquery.query,
ctePlans,
cteColumns,
tables,
outerScope,
parentColumns: subColumns?.map(name => ({ type: 'identifier', name, positionStart: 0, positionEnd: 0 })),
})
const availableColumns = inferStatementColumns({ stmt: join.subquery.query, cteColumns, tables })
if (subColumns && availableColumns.length) {
const missingColumn = subColumns.find(col => !availableColumns.includes(col))
if (missingColumn) {
throw new ColumnNotFoundError({ missingColumn, availableColumns, ...join.subquery })
}
}
const innerScope = statementScope(join.subquery.query)
rightScan = innerScope ? { type: 'Subquery', scope: innerScope, child: subPlan } : subPlan
} else {
// For CTE joins, use CTE column metadata for hints
rightHints.columns = perTableColumns.get(rightTable) ?? cteColumns?.get(join.table.toLowerCase())
const ctePlan = ctePlans?.get(join.table.toLowerCase())
/** @type {ScanOptions} */
const rightHints = {}
if (!ctePlan) {
rightHints.columns = perTableColumns.get(rightTable)
validateScan({ ...join, hints: rightHints, tables })
} else {
// For CTE joins, use CTE column metadata for hints
rightHints.columns = perTableColumns.get(rightTable) ?? cteColumns?.get(join.table.toLowerCase())
}
rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }
}
/** @type {QueryPlan} */
const rightScan = ctePlan ?? { type: 'Scan', table: join.table, hints: rightHints }

if (join.joinType === 'POSITIONAL') {
plan = { type: 'PositionalJoin', leftAlias: currentLeftTable, rightAlias: rightTable, left: plan, right: rightScan }
Expand Down
54 changes: 54 additions & 0 deletions test/execute/execute.subquery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,60 @@ describe('subqueries', () => {
})
})

describe('JOIN subquery (derived table on the right side)', () => {
it('should INNER JOIN a subquery on the right side', async () => {
const result = await collect(executeSql({
tables: { users, orders },
query: `
SELECT users.name, o.amount
FROM users
JOIN (SELECT user_id, amount FROM orders WHERE amount > 100) AS o
ON users.id = o.user_id
ORDER BY o.amount
`,
}))
expect(result).toEqual([
{ name: 'Alice', amount: 150 },
{ name: 'Bob', amount: 200 },
])
})

it('should LEFT JOIN a subquery with a LIMIT', async () => {
const result = await collect(executeSql({
tables: { users, orders },
query: `
SELECT users.name, o.amount
FROM users
LEFT JOIN (SELECT user_id, amount FROM orders ORDER BY amount DESC LIMIT 2) AS o
ON users.id = o.user_id
ORDER BY users.name
`,
}))
expect(result).toEqual([
{ name: 'Alice', amount: 150 },
{ name: 'Bob', amount: 200 },
{ name: 'Charlie', amount: null },
])
})

it('should JOIN a subquery containing an aggregate', async () => {
const result = await collect(executeSql({
tables: { users, orders },
query: `
SELECT users.name, totals.total
FROM users
JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) AS totals
ON users.id = totals.user_id
ORDER BY users.name
`,
}))
expect(result).toEqual([
{ name: 'Alice', total: 250 },
{ name: 'Bob', total: 200 },
])
})
})

describe('IN subquery', () => {
it('should filter with IN subquery', async () => {
const result = await collect(executeSql({
Expand Down
25 changes: 25 additions & 0 deletions test/parse/parse.joins.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,31 @@ describe('parseSql - JOIN queries', () => {
expect(select.joins[1].table).toBe('products')
})

it('should parse JOIN with a subquery on the right side', () => {
const select = parseSelect('SELECT * FROM users JOIN (SELECT user_id FROM orders) AS o ON users.id = o.user_id')
expect(select.joins).toHaveLength(1)
const join = select.joins[0]
expect(join.joinType).toBe('INNER')
expect(join.table).toBe('o')
expect(join.alias).toBe('o')
expect(join.subquery?.type).toBe('subquery')
expect(join.subquery?.alias).toBe('o')
expect(join.subquery?.query.type).toBe('select')
expect(join.on).toBeTruthy()
})

it('should parse LEFT JOIN with a subquery on the right side', () => {
const select = parseSelect('SELECT * FROM users LEFT JOIN (SELECT user_id FROM orders LIMIT 5) o ON users.id = o.user_id')
expect(select.joins).toHaveLength(1)
expect(select.joins[0].joinType).toBe('LEFT')
expect(select.joins[0].subquery?.alias).toBe('o')
})

it('should throw when a JOIN subquery has no alias', () => {
expect(() => parseSelect('SELECT * FROM users JOIN (SELECT user_id FROM orders) ON users.id = user_id'))
.toThrow('Subquery in JOIN must have an alias')
})

it('should parse JOIN with WHERE clause', () => {
const select = parseSelect('SELECT * FROM users JOIN orders ON users.id = orders.user_id WHERE orders.total > 100')
expect(select.joins).toHaveLength(1)
Expand Down