Skip to content
Open
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
5 changes: 5 additions & 0 deletions errors.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2701,6 +2701,11 @@ error = '''
Variable '%s' might not be affected by SET_VAR hint.
'''

["planner:3809"]
error = '''
Invalid use of LATERAL: %s
'''

["planner:8006"]
error = '''
`%s` is unsupported on temporary tables.
Expand Down
1 change: 1 addition & 0 deletions pkg/errno/errcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,7 @@ const (
ErrDefValGeneratedNamedFunctionIsNotAllowed = 3770
ErrFKIncompatibleColumns = 3780
ErrFunctionalIndexRowValueIsNotAllowed = 3800
ErrInvalidLateralJoin = 3809
ErrNonBooleanExprForCheckConstraint = 3812
ErrColumnCheckConstraintReferencesOtherColumn = 3813
ErrCheckConstraintNamedFunctionIsNotAllowed = 3814
Expand Down
1 change: 1 addition & 0 deletions pkg/errno/errname.go
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ var MySQLErrName = map[uint16]*mysql.ErrMessage{
ErrFunctionalIndexOnField: mysql.Message("Expression index on a column is not supported. Consider using a regular index instead", nil),
ErrFKIncompatibleColumns: mysql.Message("Referencing column '%s' and referenced column '%s' in foreign key constraint '%s' are incompatible.", nil),
ErrFunctionalIndexRowValueIsNotAllowed: mysql.Message("Expression of expression index '%s' cannot refer to a row value", nil),
ErrInvalidLateralJoin: mysql.Message("Invalid use of LATERAL: %s", nil),
ErrNonBooleanExprForCheckConstraint: mysql.Message("An expression of non-boolean type specified to a check constraint '%s'.", nil),
ErrColumnCheckConstraintReferencesOtherColumn: mysql.Message("Column check constraint '%s' references other column.", nil),
ErrCheckConstraintNamedFunctionIsNotAllowed: mysql.Message("An expression of a check constraint '%s' contains disallowed function: %s.", nil),
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ go_test(
"digester_test.go",
"hintparser_test.go",
"keywords_test.go",
"lateral_test.go",
"lexer_test.go",
"main_test.go",
"parser_test.go",
Expand Down
24 changes: 24 additions & 0 deletions pkg/parser/ast/dml.go
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,15 @@ type TableSource struct {

// AsName is the alias name of the table source.
AsName CIStr

// Lateral indicates whether this is a LATERAL derived table.
// MySQL 8.0+ syntax: FROM t1, LATERAL (SELECT ...) AS dt
// LATERAL allows the derived table to reference columns from tables to its left.
Lateral bool

// ColumnNames is the optional column alias list for derived tables.
// e.g. LATERAL (SELECT ...) AS dt(c1, c2)
ColumnNames []CIStr
}

func (*TableSource) resultSet() {}
Expand All @@ -547,6 +556,11 @@ func (n *TableSource) Restore(ctx *format.RestoreCtx) error {
needParen = true
}

// Output LATERAL keyword if this is a LATERAL derived table
if n.Lateral {
ctx.WriteKeyWord("LATERAL ")
}

if tn, tnCase := n.Source.(*TableName); tnCase {
if needParen {
ctx.WritePlain("(")
Expand Down Expand Up @@ -592,6 +606,16 @@ func (n *TableSource) Restore(ctx *format.RestoreCtx) error {
if asName := n.AsName.String(); asName != "" {
ctx.WriteKeyWord(" AS ")
ctx.WriteName(asName)
if len(n.ColumnNames) > 0 {
ctx.WritePlain("(")
for i, col := range n.ColumnNames {
if i > 0 {
ctx.WritePlain(", ")
}
ctx.WriteName(col.String())
}
ctx.WritePlain(")")
}
}
}

Expand Down
1 change: 1 addition & 0 deletions pkg/parser/keywords.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pkg/parser/keywords_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ func TestKeywords(t *testing.T) {
}

func TestKeywordsLength(t *testing.T) {
require.Equal(t, 676, len(parser.Keywords))
require.Equal(t, 677, len(parser.Keywords))

reservedNr := 0
for _, kw := range parser.Keywords {
if kw.Reserved {
reservedNr += 1
}
}
require.Equal(t, 232, reservedNr)
require.Equal(t, 233, reservedNr)
}

func TestKeywordsSorting(t *testing.T) {
Expand Down
196 changes: 196 additions & 0 deletions pkg/parser/lateral_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright 2026 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package parser_test

import (
"strings"
"testing"

"github.com/pingcap/tidb/pkg/parser"
"github.com/pingcap/tidb/pkg/parser/ast"
"github.com/pingcap/tidb/pkg/parser/format"
"github.com/stretchr/testify/require"
)

func TestLateralParsing(t *testing.T) {
p := parser.New()

testCases := []struct {
name string
sql string
expectError bool
checkLateral bool // whether to verify Lateral flag is set
columnNames []string
}{
{
name: "LATERAL with comma syntax",
sql: "SELECT * FROM t1, LATERAL (SELECT t1.a) AS dt",
expectError: false,
checkLateral: true,
},
{
name: "LATERAL with LEFT JOIN",
sql: "SELECT * FROM t1 LEFT JOIN LATERAL (SELECT t1.b) AS dt ON true",
expectError: false,
checkLateral: true,
},
{
name: "LATERAL with CROSS JOIN",
sql: "SELECT * FROM t1 CROSS JOIN LATERAL (SELECT t1.c) AS dt",
expectError: false,
checkLateral: true,
},
{
name: "LATERAL with RIGHT JOIN",
sql: "SELECT * FROM t1 RIGHT JOIN LATERAL (SELECT t1.d) AS dt ON true",
expectError: false, // Parser allows it, planner will reject
checkLateral: true,
},
{
name: "LATERAL with INNER JOIN",
sql: "SELECT * FROM t1 JOIN LATERAL (SELECT t1.e) AS dt ON true",
expectError: false,
checkLateral: true,
},
{
name: "LATERAL with complex subquery",
sql: "SELECT * FROM t1, LATERAL (SELECT t1.a, COUNT(*) FROM t2 WHERE t2.x = t1.x GROUP BY t1.a) AS dt",
expectError: false,
checkLateral: true,
},
{
name: "LATERAL with nested subquery",
sql: "SELECT * FROM t1, LATERAL (SELECT * FROM (SELECT t1.a) AS inner_dt) AS dt",
expectError: false,
checkLateral: true,
},
{
name: "Multiple LATERAL joins",
sql: "SELECT * FROM t1, LATERAL (SELECT t1.a) AS dt1, LATERAL (SELECT t1.b) AS dt2",
expectError: false,
checkLateral: true,
},
{
name: "Non-LATERAL derived table",
sql: "SELECT * FROM t1, (SELECT a FROM t2) AS dt",
expectError: false,
// Lateral flag should be false for non-LATERAL
},
{
name: "LATERAL with WHERE clause",
sql: "SELECT * FROM t1, LATERAL (SELECT * FROM t2 WHERE t2.x = t1.x) AS dt WHERE dt.y > 10",
expectError: false,
checkLateral: true,
},
{
name: "LATERAL with column list",
sql: "SELECT * FROM t1, LATERAL (SELECT t1.a, t1.b) AS dt(c1, c2)",
expectError: false,
checkLateral: true,
columnNames: []string{"c1", "c2"},
},
{
name: "LATERAL with column list no AS",
sql: "SELECT * FROM t1, LATERAL (SELECT t1.a) dt(col1)",
expectError: false,
checkLateral: true,
columnNames: []string{"col1"},
},
{
name: "LATERAL with column list and JOIN",
sql: "SELECT * FROM t1 LEFT JOIN LATERAL (SELECT t1.a, t1.b, t1.c) AS dt(x, y, z) ON true",
expectError: false,
checkLateral: true,
columnNames: []string{"x", "y", "z"},
},
{
name: "LATERAL without alias is rejected",
sql: "SELECT * FROM t1, LATERAL (SELECT t1.a)",
expectError: true,
},
}
Comment on lines +30 to +123
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing regression coverage for the reported LATERAL decorrelation correctness bug.

This suite validates parsing/AST/restore, but it does not cover the concrete planner/execution regression reported in this PR discussion (join tree + ON + LATERAL producing wrong zero counts). Please add a regression test in planner/integration tests using that SQL shape and assert MySQL-compatible non-zero counts so Apply→Join rewrite regressions are caught.

Based on learnings: "Applies to **/*.go : MUST add a regression test and verify it fails before fix and passes after fix for bug fixes."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/parser/lateral_test.go` around lines 30 - 123, Add a planner integration
regression test (e.g., TestLateralApplyJoinDecorrelationRegression) that
executes the problematic join-tree+ON+LATERAL SQL shape (use a query that
mirrors the bug: a base table t1 LEFT JOIN LATERAL (subquery referencing t1) ...
with GROUP BY/COUNT) and assert MySQL-compatible non-zero counts for the grouped
results; ensure the test runs in the planner/integration test harness, uses
existing helpers to prepare test data, runs the SQL, and compares COUNT(*)
results to expected non-zero values so the test fails before the fix and passes
after (refer to the LATERAL occurrences in the current lateral_test cases and
the Apply→Join rewrite path to locate where to add the new test).


for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
stmt, err := p.ParseOneStmt(tc.sql, "", "")

if tc.expectError {
require.Error(t, err, "Expected parsing to fail for: %s", tc.sql)
return
}

require.NoError(t, err, "Failed to parse: %s", tc.sql)
require.NotNil(t, stmt)

// Test round-trip: parse -> restore -> parse again
var sb strings.Builder
restoreCtx := format.NewRestoreCtx(format.RestoreStringSingleQuotes, &sb)
err = stmt.Restore(restoreCtx)
require.NoError(t, err, "Failed to restore statement")

restored := sb.String()
if tc.checkLateral {
// Verify LATERAL keyword is preserved in restoration
require.Contains(t, restored, "LATERAL", "LATERAL keyword missing in restored SQL: %s", restored)
}

// Parse the restored SQL to ensure it's valid (round-trip test)
stmt2, err := p.ParseOneStmt(restored, "", "")
require.NoError(t, err, "Failed to parse restored SQL: %s", restored)
require.NotNil(t, stmt2)

// Verify AST flag: check if LATERAL table sources exist
if tc.checkLateral {
selectStmt, ok := stmt.(*ast.SelectStmt)
require.True(t, ok, "Statement should be SelectStmt")
require.NotNil(t, selectStmt.From, "FROM clause should not be nil")

// Verify at least one LATERAL table source exists in the FROM clause
lateralTS := findLateralTableSource(selectStmt.From.TableRefs)
require.NotNil(t, lateralTS, "LATERAL TableSource not found in AST for: %s", tc.sql)

// Verify column names if expected
if len(tc.columnNames) > 0 {
require.Len(t, lateralTS.ColumnNames, len(tc.columnNames), "column name count mismatch")
for i, expected := range tc.columnNames {
require.Equal(t, expected, lateralTS.ColumnNames[i].L, "column name mismatch at index %d", i)
}
}
}
})
}
}

// findLateralTableSource recursively searches for the first LATERAL TableSource in a ResultSetNode.
func findLateralTableSource(node ast.ResultSetNode) *ast.TableSource {
if node == nil {
return nil
}

switch n := node.(type) {
case *ast.TableSource:
if n.Lateral {
return n
}
return findLateralTableSource(n.Source)
case *ast.Join:
if ts := findLateralTableSource(n.Left); ts != nil {
return ts
}
return findLateralTableSource(n.Right)
}

return nil
}
1 change: 1 addition & 0 deletions pkg/parser/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ var tokenMap = map[string]int{
"LAST_BACKUP": lastBackup,
"LAST": last,
"LASTVAL": lastval,
"LATERAL": lateral,
"LEADER": leader,
"LEADER_CONSTRAINTS": leaderConstraints,
"LEADING": leading,
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/mysql/errcode.go
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,7 @@ const (
ErrFunctionalIndexOnField = 3762
ErrFKIncompatibleColumns = 3780
ErrFunctionalIndexRowValueIsNotAllowed = 3800
ErrInvalidLateralJoin = 3809
ErrDependentByFunctionalIndex = 3837
ErrInvalidJSONType = 3853
ErrInvalidJsonValueForFuncIndex = 3903 //nolint: revive
Expand Down
Loading
Loading