Support implicit lateral joins for set-returning functions in FROM - #3152
Support implicit lateral joins for set-returning functions in FROM#3152zachmu wants to merge 1 commit into
Conversation
In Postgres, a function called in the FROM list may reference columns of tables that precede it in the same FROM clause: it is an implicit LATERAL join, and the LATERAL keyword is a noise word for function-call FROM items. Queries like the following now work: SELECT k.u FROM pg_index i, unnest(i.indkey) AS k(u) WHERE i.indexrelid = 'bug15_ab'::regclass; Function-call FROM items that follow another FROM item are now marked as lateral during AST conversion, and lateral function items are converted to a TableFuncExpr wrapped in a lateral subquery, which GMS knows how to scope and execute. This also fixes the explicit LATERAL keyword before a function call in FROM, which previously failed with an unsupported-syntax error, and WITH ORDINALITY over such functions. Fixes #3112
|
SummaryCoverage spans correlated array expansion across common join forms, aliasing, row numbering, independent functions, and scope boundaries, including edge cases such as empty arrays and nested joins. The core query behavior is broadly healthy, but outer-join preservation has a correctness gap that can silently omit source data. Merge with caution — this PR causes a medium-severity data-correctness failure in left joins when correlated expansion produces no rows, so affected results can be incomplete. A separate medium-severity parenthesized-join failure is unrelated to the PR and is a flag for later. Tests run by ItoAdditional Findings DetailsThese findings are unrelated to the current changes but were observed during testing. 🟡 Parenthesized joins fail before scope checking
Evidence PackageTip Reply with @itoqa to send us feedback on this test run. |
| node.Where = nil | ||
| } | ||
| PostJoinRewrite: | ||
| // In Postgres, a function called in the FROM list may reference columns of tables that precede it in the same |
There was a problem hiding this comment.
Empty lateral results drop source rows
What failed: The query returned only (2, 10) and (2, 20). It omitted the required (1, NULL) row even though the join was a LEFT JOIN.
Impact · Steps · Stub / mock · Analysis · Why this is likely a bug
- Severity: Medium
- Impact: Queries using a left lateral join can silently omit source rows when the function returns no values. Reports and data processing that rely on those results may be incomplete.
- Steps to Reproduce:
- Create a table with one row containing an empty integer array and another row containing [10, 20].
- Run SELECT t.id, u FROM t LEFT JOIN LATERAL unnest(t.values) AS x(u) ON true ORDER BY t.id, u NULLS FIRST.
- Check that the empty-array row is returned with a NULL function value, alongside the two values from the non-empty row.
- Stub / mock content: No stubs, mocks, or bypasses were applied for this test in the recorded run.
- Code Analysis: In server/ast/select_clause.go, nodeSelectClause calls markImplicitLateralFunctions before converting the FROM clause (lines 112-117). That PR-added helper marks a following RowsFromExpr as lateral (lines 170-194). Later, rewriteTableFuncExprs recognizes the function wrapper and, when expr.Lateral is true, converts the TableFuncExpr into an AliasedTableExpr containing a lateral subquery (lines 242-260). This makes the preceding table visible to the function, but the resulting execution path does not preserve the outer join's null-extended row when the table function produces zero rows. The smallest practical fix is in this lateral conversion path: preserve LEFT JOIN semantics for an empty TableFuncExpr result, either by representing the lateral function in a form that the existing join executor null-extends or by adding the narrow null-extension handling at this wrapper boundary; do not change unrelated non-lateral function or subquery behavior.
- Why this is likely a bug: This is a real local Doltgres execution failure, not a harness-only symptom: the SQL command succeeded and consistently returned two rows instead of the three rows required by PostgreSQL outer-join semantics. The fixture uses ordinary integer arrays and the recorded setup did not stub or intercept the database behavior. The PR changed the exact AST conversion path used by the query, and the missing row is silently lost rather than reported as an unsupported query. A targeted fix to the new lateral wrapper or its join representation should restore null extension while retaining the PR's intended correlated-function support.
Relevant code
server/ast/select_clause.go:112-117
// We mirror that here by marking any function-call FROM item that follows another FROM item as lateral before
// converting the FROM clause.
markImplicitLateralFunctions(node.From.Tables)
from, err := nodeFrom(ctx, node.From)server/ast/select_clause.go:242-260
tableFuncExpr := &vitess.TableFuncExpr{
Name: funcExpr.Name.String(),
Exprs: funcExpr.Exprs,
Alias: alias,
Columns: subquery.Columns,
}
if expr.Lateral {
return &vitess.AliasedTableExpr{
Expr: &vitess.Subquery{
Select: &vitess.Select{
SelectExprs: vitess.SelectExprs{&vitess.StarExpr{}},
From: vitess.TableExprs{tableFuncExpr},
},
},
As: alias,
Lateral: true,
}server/ast/select_clause.go:170-181
// markImplicitLateralFunctions marks function-call FROM items that follow another FROM item as lateral.
func markImplicitLateralFunctions(tables tree.TableExprs) {
var mark func(table tree.TableExpr, followsFromItem bool)
mark = func(table tree.TableExpr, followsFromItem bool) {
switch table := table.(type) {
case *tree.AliasedTableExpr:
if followsFromItem {
if _, ok := table.Expr.(*tree.RowsFromExpr); ok {
table.Lateral = trueEvidence Package
Copy prompt for an agent
Ito QA identified the following failure during automated PR testing. Please investigate and propose a fix.
**Medium severity — Empty lateral results drop source rows**
**What failed:** The query returned only (2, 10) and (2, 20). It omitted the required (1, NULL) row even though the join was a LEFT JOIN.
- **Impact:** Queries using a left lateral join can silently omit source rows when the function returns no values. Reports and data processing that rely on those results may be incomplete.
- **Steps to reproduce:**
1. Create a table with one row containing an empty integer array and another row containing [10, 20].
2. Run SELECT t.id, u FROM t LEFT JOIN LATERAL unnest(t.values) AS x(u) ON true ORDER BY t.id, u NULLS FIRST.
3. Check that the empty-array row is returned with a NULL function value, alongside the two values from the non-empty row.
- **Stub / mock content:** No stubs, mocks, or bypasses were applied for this test in the recorded run.
- **Code analysis:** In server/ast/select_clause.go, nodeSelectClause calls markImplicitLateralFunctions before converting the FROM clause (lines 112-117). That PR-added helper marks a following RowsFromExpr as lateral (lines 170-194). Later, rewriteTableFuncExprs recognizes the function wrapper and, when expr.Lateral is true, converts the TableFuncExpr into an AliasedTableExpr containing a lateral subquery (lines 242-260). This makes the preceding table visible to the function, but the resulting execution path does not preserve the outer join's null-extended row when the table function produces zero rows. The smallest practical fix is in this lateral conversion path: preserve LEFT JOIN semantics for an empty TableFuncExpr result, either by representing the lateral function in a form that the existing join executor null-extends or by adding the narrow null-extension handling at this wrapper boundary; do not change unrelated non-lateral function or subquery behavior.
- **Why this is likely a bug:** This is a real local Doltgres execution failure, not a harness-only symptom: the SQL command succeeded and consistently returned two rows instead of the three rows required by PostgreSQL outer-join semantics. The fixture uses ordinary integer arrays and the recorded setup did not stub or intercept the database behavior. The PR changed the exact AST conversion path used by the query, and the missing row is silently lost rather than reported as an unsupported query. A targeted fix to the new lateral wrapper or its join representation should restore null extension while retaining the PR's intended correlated-function support.
**Relevant code:**
`server/ast/select_clause.go:112-117`
~~~go
// We mirror that here by marking any function-call FROM item that follows another FROM item as lateral before
// converting the FROM clause.
markImplicitLateralFunctions(node.From.Tables)
from, err := nodeFrom(ctx, node.From)
~~~
`server/ast/select_clause.go:242-260`
~~~go
tableFuncExpr := &vitess.TableFuncExpr{
Name: funcExpr.Name.String(),
Exprs: funcExpr.Exprs,
Alias: alias,
Columns: subquery.Columns,
}
if expr.Lateral {
return &vitess.AliasedTableExpr{
Expr: &vitess.Subquery{
Select: &vitess.Select{
SelectExprs: vitess.SelectExprs{&vitess.StarExpr{}},
From: vitess.TableExprs{tableFuncExpr},
},
},
As: alias,
Lateral: true,
}
~~~
`server/ast/select_clause.go:170-181`
~~~go
// markImplicitLateralFunctions marks function-call FROM items that follow another FROM item as lateral.
func markImplicitLateralFunctions(tables tree.TableExprs) {
var mark func(table tree.TableExpr, followsFromItem bool)
mark = func(table tree.TableExpr, followsFromItem bool) {
switch table := table.(type) {
case *tree.AliasedTableExpr:
if followsFromItem {
if _, ok := table.Expr.(*tree.RowsFromExpr); ok {
table.Lateral = true
~~~
|
|
@zachmu DOLT
|


Set-returning functions in the FROM list can now reference columns of preceding tables (implicit and explicit LATERAL joins).
Fixes #3112.