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
8 changes: 4 additions & 4 deletions pkg/executor/partition_table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1548,7 +1548,7 @@ func TestParallelApply(t *testing.T) {
` └─IndexReader 10000.00 root partition:all index:HashAgg`, // IndexReader is a inner child of Apply
` └─HashAgg 10000.00 cop[tikv] funcs:sum(test_parallel_apply.thash.a)->Column#10`,
` └─Selection 80000000.00 cop[tikv] gt(test_parallel_apply.thash.a, test_parallel_apply.touter.b)`,
` └─IndexFullScan 100000000.00 cop[tikv] table:thash, index:a(a) keep order:false, stats:pseudo`))
` └─IndexRangeScan 100000000.00 cop[tikv] table:thash, index:a(a) range: decided by [gt(test_parallel_apply.thash.a, test_parallel_apply.touter.b)], keep order:false, stats:pseudo`))
tk.MustQuery(`select * from touter where touter.a > (select sum(thash.a) from thash use index(a) where thash.a>touter.b)`).Sort().Check(
tk.MustQuery(`select * from touter where touter.a > (select sum(tinner.a) from tinner use index(a) where tinner.a>touter.b)`).Sort().Rows())

Expand All @@ -1575,7 +1575,7 @@ func TestParallelApply(t *testing.T) {
` └─HashAgg(Probe) 10000.00 root funcs:sum(Column#11)->Column#9`,
` └─IndexLookUp 10000.00 root `, // IndexLookUp is a inner child of Apply
` ├─Selection(Build) 80000000.00 cop[tikv] gt(test_parallel_apply.tinner.a, test_parallel_apply.touter.b)`,
` │ └─IndexFullScan 100000000.00 cop[tikv] table:tinner, index:a(a) keep order:false, stats:pseudo`,
` │ └─IndexRangeScan 100000000.00 cop[tikv] table:tinner, index:a(a) range: decided by [gt(test_parallel_apply.tinner.a, test_parallel_apply.touter.b)], keep order:false, stats:pseudo`,
` └─HashAgg(Probe) 10000.00 cop[tikv] funcs:sum(test_parallel_apply.tinner.b)->Column#11`,
` └─TableRowIDScan 80000000.00 cop[tikv] table:tinner keep order:false, stats:pseudo`))
tk.MustQuery(`select * from touter where touter.a > (select sum(thash.b) from thash use index(a) where thash.a>touter.b)`).Sort().Check(
Expand All @@ -1591,7 +1591,7 @@ func TestParallelApply(t *testing.T) {
` └─IndexReader 10000.00 root partition:all index:HashAgg`, // IndexReader is a inner child of Apply
` └─HashAgg 10000.00 cop[tikv] funcs:sum(test_parallel_apply.trange.a)->Column#10`,
` └─Selection 80000000.00 cop[tikv] gt(test_parallel_apply.trange.a, test_parallel_apply.touter.b)`,
` └─IndexFullScan 100000000.00 cop[tikv] table:trange, index:a(a) keep order:false, stats:pseudo`))
` └─IndexRangeScan 100000000.00 cop[tikv] table:trange, index:a(a) range: decided by [gt(test_parallel_apply.trange.a, test_parallel_apply.touter.b)], keep order:false, stats:pseudo`))
tk.MustQuery(`select * from touter where touter.a > (select sum(trange.a) from trange use index(a) where trange.a>touter.b)`).Sort().Check(
tk.MustQuery(`select * from touter where touter.a > (select sum(tinner.a) from tinner use index(a) where tinner.a>touter.b)`).Sort().Rows())

Expand All @@ -1618,7 +1618,7 @@ func TestParallelApply(t *testing.T) {
` └─HashAgg(Probe) 10000.00 root funcs:sum(Column#11)->Column#9`,
` └─IndexLookUp 10000.00 root `, // IndexLookUp is a inner child of Apply
` ├─Selection(Build) 80000000.00 cop[tikv] gt(test_parallel_apply.tinner.a, test_parallel_apply.touter.b)`,
` │ └─IndexFullScan 100000000.00 cop[tikv] table:tinner, index:a(a) keep order:false, stats:pseudo`,
` │ └─IndexRangeScan 100000000.00 cop[tikv] table:tinner, index:a(a) range: decided by [gt(test_parallel_apply.tinner.a, test_parallel_apply.touter.b)], keep order:false, stats:pseudo`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These are good changes, IndexRangeScan should be better than IndexFullScan.

` └─HashAgg(Probe) 10000.00 cop[tikv] funcs:sum(test_parallel_apply.tinner.b)->Column#11`,
` └─TableRowIDScan 80000000.00 cop[tikv] table:tinner keep order:false, stats:pseudo`))
tk.MustQuery(`select * from touter where touter.a > (select sum(trange.b) from trange use index(a) where trange.a>touter.b)`).Sort().Check(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@
" └─MaxOneRow(Probe) 3.00 root ",
" └─IndexMerge 3.60 root partition:all type: intersection",
" ├─IndexRangeScan(Build) 6.00 cop[tikv] table:t1, index:ia(a) range:(10,+inf], keep order:false",
" ├─Selection(Build) 7.20 cop[tikv] gt(test.t1.c, test.t2.a)",
" │ └─IndexRangeScan 9.00 cop[tikv] table:t1, index:ibc(b, c) range:[20,20], keep order:false",
" ├─Selection(Build) 3.60 cop[tikv] gt(test.t1.c, test.t2.a)",
" │ └─IndexRangeScan 4.00 cop[tikv] table:t1, index:ibc(b, c) range: decided by [eq(test.t1.b, 20) gt(test.t1.c, test.t2.a)], keep order:false",
" └─TableRowIDScan(Probe) 3.60 cop[tikv] table:t1 keep order:false"
],
"Result": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@
" └─MaxOneRow(Probe) 3.00 root ",
" └─IndexMerge 3.60 root partition:all type: intersection",
" ├─IndexRangeScan(Build) 6.00 cop[tikv] table:t1, index:ia(a) range:(10,+inf], keep order:false",
" ├─Selection(Build) 7.20 cop[tikv] gt(test.t1.c, test.t2.a)",
" │ └─IndexRangeScan 9.00 cop[tikv] table:t1, index:ibc(b, c) range:[20,20], keep order:false",
" ├─Selection(Build) 3.60 cop[tikv] gt(test.t1.c, test.t2.a)",
" │ └─IndexRangeScan 4.00 cop[tikv] table:t1, index:ibc(b, c) range: decided by [eq(test.t1.b, 20) gt(test.t1.c, test.t2.a)], keep order:false",
" └─TableRowIDScan(Probe) 3.60 cop[tikv] table:t1 keep order:false"
],
"Result": [
Expand Down
168 changes: 168 additions & 0 deletions pkg/planner/core/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,174 @@ func TestCorColRangeWithRangeMaxSize(t *testing.T) {
})
}

// TestCorColRangePredicateAccess verifies that range predicates (LT/GT/LE/GE)
// with correlated columns are used as index access conditions, not just
// post-scan Selection filters.
func TestCorColRangePredicateAccess(t *testing.T) {
testkit.RunTestUnderCascades(t, func(t *testing.T, tk *testkit.TestKit, cascades, caller string) {
tk.MustExec("use test")
tk.MustExec("drop table if exists t1")
tk.MustExec("create table t1 (a int primary key, b int, index ib(b))")
tk.MustExec("insert into t1 values (1,1),(2,2),(3,3),(4,4),(5,5)")
tk.MustExec("insert into mysql.opt_rule_blacklist value(\"decorrelate\")")
tk.MustExec("admin reload opt_rule_blacklist")
defer func() {
tk.MustExec("delete from mysql.opt_rule_blacklist where name = \"decorrelate\"")
tk.MustExec("admin reload opt_rule_blacklist")
}()

// LT with correlated column on PK (appended to secondary index ib(b)).
// The index is effectively (b, a) on a clustered table, so t1b.a < t1a.a
// should appear as an access condition in "decided by".
rows := tk.MustQuery("explain format='brief' select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a < t1a.a)").Rows()
foundRangeAccess := false
for _, row := range rows {
info := fmt.Sprintf("%v", row[4])
if strings.Contains(info, "decided by") && strings.Contains(info, "lt(test.t1.a, test.t1.a)") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P2] Test asserts raw EXPLAIN string fragments — brittle CI

Why: strings.Contains matching against human-readable EXPLAIN FORMAT='brief' output means any unrelated formatting change (operator rename, column-qualification change) can silently break this test despite correct plan semantics.

Evidence: pkg/planner/core/integration_test.go:1607 — strings.Contains(info, "lt(test.t1.a, test.t1.a)"). The same fragile pattern also appears at lines 1637 (gt(...)) and 1659 (le(...)). Consider instead asserting on a structured plan representation or a purpose-built test helper that is decoupled from EXPLAIN's exact text format.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is not a critical concern. Many tests use this type of string comparison to search for text within the plan.

@pantheon-ai pantheon-ai Bot Mar 5, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fair point — strings.Contains on EXPLAIN FORMAT='brief' output is a well-established convention throughout TiDB's integration tests, so this is consistent with the project's existing style. The P2 flag was a low-priority heads-up rather than a blocker. No action needed here.

ℹ️ Learn more details on Pantheon AI.

foundRangeAccess = true
break
}
}
require.True(t, foundRangeAccess, caller+": LT correlated predicate should be in index access conditions")

// Correctness: rows where exists another row with same b but smaller a.
// (1,1) -> no row with b=1 and a<1 -> excluded
// (2,2) -> no row with b=2 and a<2 -> excluded
// ...all excluded since each (a,b) pair has a=b and is unique.
tk.MustQuery("select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a < t1a.a)").
Check(testkit.Rows())

// Insert duplicate b values so the EXISTS matches.
tk.MustExec("insert into t1 values (10,1),(20,2)")
// (1,1) -> exists (10,1) with b=1, a=10 > 1? No, need a < 1. -> excluded
// But (10,1) -> exists (1,1) with b=1, a=1 < 10 -> included
// (20,2) -> exists (2,2) with b=2, a=2 < 20 -> included
tk.MustQuery("select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a < t1a.a) order by t1a.a").
Check(testkit.Rows("10 1", "20 2"))

// GT with correlated column.
rows = tk.MustQuery("explain format='brief' select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a > t1a.a)").Rows()
foundRangeAccess = false
for _, row := range rows {
info := fmt.Sprintf("%v", row[4])
if strings.Contains(info, "decided by") && strings.Contains(info, "gt(test.t1.a, test.t1.a)") {
foundRangeAccess = true
break
}
}
require.True(t, foundRangeAccess, caller+": GT correlated predicate should be in index access conditions")

// Correctness for GT.
// (1,1) -> exists (10,1) with b=1, a=10 > 1 -> included
// (2,2) -> exists (20,2) with b=2, a=20 > 2 -> included
// (10,1) -> need b=1 and a>10, only (1,1) has b=1 but a=1 < 10 -> excluded
// (20,2) -> need b=2 and a>20, only (2,2) has b=2 but a=2 < 20 -> excluded
tk.MustQuery("select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a > t1a.a) order by t1a.a").
Check(testkit.Rows("1 1", "2 2"))

// LE with correlated column.
rows = tk.MustQuery("explain format='brief' select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a <= t1a.a)").Rows()
foundRangeAccess = false
for _, row := range rows {
info := fmt.Sprintf("%v", row[4])
if strings.Contains(info, "decided by") && strings.Contains(info, "le(test.t1.a, test.t1.a)") {
foundRangeAccess = true
break
}
}
require.True(t, foundRangeAccess, caller+": LE correlated predicate should be in index access conditions")

// Correctness for LE.
// (1,1) -> exists (1,1) with b=1, a=1 <= 1 -> included (self-match)
// (2,2) -> exists (2,2) with b=2, a=2 <= 2 -> included (self-match)
// (3,3) -> exists (3,3) with b=3, a=3 <= 3 -> included (self-match)
// (4,4) -> exists (4,4) with b=4, a=4 <= 4 -> included (self-match)
// (5,5) -> exists (5,5) with b=5, a=5 <= 5 -> included (self-match)
// (10,1) -> exists (1,1) with b=1, a=1 <= 10 -> included
// (20,2) -> exists (2,2) with b=2, a=2 <= 20 -> included
tk.MustQuery("select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a <= t1a.a) order by t1a.a").
Check(testkit.Rows("1 1", "2 2", "3 3", "4 4", "5 5", "10 1", "20 2"))

// GE with correlated column.
rows = tk.MustQuery("explain format='brief' select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a >= t1a.a)").Rows()
foundRangeAccess = false
for _, row := range rows {
info := fmt.Sprintf("%v", row[4])
if strings.Contains(info, "decided by") && strings.Contains(info, "ge(test.t1.a, test.t1.a)") {
foundRangeAccess = true
break
}
}
require.True(t, foundRangeAccess, caller+": GE correlated predicate should be in index access conditions")

// Correctness for GE.
// (1,1) -> exists (1,1) with b=1, a=1 >= 1 -> included (self-match)
// (2,2) -> exists (2,2) with b=2, a=2 >= 2 -> included (self-match)
// (3,3) -> exists (3,3) with b=3, a=3 >= 3 -> included (self-match)
// (4,4) -> exists (4,4) with b=4, a=4 >= 4 -> included (self-match)
// (5,5) -> exists (5,5) with b=5, a=5 >= 5 -> included (self-match)
// (10,1) -> exists (10,1) with b=1, a=10 >= 10 -> included (self-match)
// (20,2) -> exists (20,2) with b=2, a=20 >= 20 -> included (self-match)
tk.MustQuery("select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1b.a >= t1a.a) order by t1a.a").
Check(testkit.Rows("1 1", "2 2", "3 3", "4 4", "5 5", "10 1", "20 2"))

// Reversed argument order: correlated column on the left side of
// the comparison (t1a.a < t1b.a instead of t1b.a > t1a.a) must
// also be recognised as an index access condition.
rows = tk.MustQuery("explain format='brief' select * from t1 t1a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t1 t1b where t1b.b = t1a.b and t1a.a < t1b.a)").Rows()
foundRangeAccess = false
for _, row := range rows {
info := fmt.Sprintf("%v", row[4])
if strings.Contains(info, "decided by") && strings.Contains(info, "lt(test.t1.a, test.t1.a)") {
foundRangeAccess = true
break
}
}
require.True(t, foundRangeAccess, caller+": reversed-arg LT correlated predicate should be in index access conditions")

// NULL outer correlated value: col > NULL is NULL (not true), so
// the correlated range must not produce false-positive matches.
// Use a separate table since t1.a is PK and cannot be NULL.
tk.MustExec("drop table if exists t2")
tk.MustExec("create table t2 (a int, b int, index ib(b))")
tk.MustExec("insert into t2 values (1,1),(2,2),(3,3),(NULL,1)")
// Outer row (NULL,1): inner needs b=1 and a < NULL -> no match.
// Only (2,2) and (3,3) have inner rows with same b and smaller a... no,
// only rows with duplicate b values can match. b=1 has (1,1) and (NULL,1).
// (1,1) -> inner (NULL,1) has b=1, a=NULL < 1? -> NULL, no match. -> excluded
// (NULL,1) -> inner needs b=1 and a < NULL -> NULL, no match. -> excluded
// (2,2) -> only b=2 row is (2,2) itself, a=2 < 2 is false -> excluded
// (3,3) -> only b=3 row is (3,3) itself, a=3 < 3 is false -> excluded
tk.MustQuery("select * from t2 t2a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t2 t2b where t2b.b = t2a.b and t2b.a < t2a.a) order by t2a.a").
Check(testkit.Rows())
// GT: (NULL,1) -> inner needs b=1 and a > NULL -> no match.
// (1,1) -> inner (NULL,1) b=1, a=NULL > 1? -> NULL, no match. -> excluded
tk.MustQuery("select * from t2 t2a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t2 t2b where t2b.b = t2a.b and t2b.a > t2a.a) order by t2a.a").
Check(testkit.Rows())
// LE: (1,1) -> inner (1,1) b=1, a=1 <= 1 -> true (self). (NULL,1) -> a <= NULL -> no match.
tk.MustQuery("select * from t2 t2a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t2 t2b where t2b.b = t2a.b and t2b.a <= t2a.a) order by t2a.a").
Check(testkit.Rows("1 1", "2 2", "3 3"))
// GE: same as LE with self-match.
tk.MustQuery("select * from t2 t2a where exists " +
"(select /*+ NO_DECORRELATE() */ 1 from t2 t2b where t2b.b = t2a.b and t2b.a >= t2a.a) order by t2a.a").
Check(testkit.Rows("1 1", "2 2", "3 3"))
tk.MustExec("drop table t2")
})
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// TestExplainAnalyzeDMLCommit covers the issue #37373.
func TestExplainAnalyzeDMLCommit(t *testing.T) {
testkit.RunTestUnderCascades(t, func(t *testing.T, tk *testkit.TestKit, cascades, caller string) {
Expand Down
61 changes: 61 additions & 0 deletions pkg/planner/util/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ func (path *AccessPath) IsTiFlashSimpleTablePath() bool {

// SplitCorColAccessCondFromFilters move the necessary filter in the form of index_col = corrlated_col to access conditions.
// The function consider the `idx_col_1 = const and index_col_2 = cor_col and index_col_3 = const` case.
// It also supports a trailing range predicate (LT/GT/LE/GE) with a correlated column on the last
// access column, e.g. `idx_col_1 = cor_col AND idx_col_2 < cor_col`.
// It enables more index columns to be considered. The range will be rebuilt in 'ResolveCorrelatedColumns'.
func (path *AccessPath) SplitCorColAccessCondFromFilters(ctx planctx.PlanContext, eqOrInCount int) (access, remained []expression.Expression) {
access = make([]expression.Expression, len(path.IdxCols)-eqOrInCount)
Expand Down Expand Up @@ -276,6 +278,29 @@ func (path *AccessPath) SplitCorColAccessCondFromFilters(ctx planctx.PlanContext
break
}
if !matched {
// Try a range predicate (LT/GT/LE/GE) with a correlated column.
// A range predicate terminates the access prefix, so we only
// attempt this when no EQ match was found.
for j, filter := range path.TableFilters {
if used[j] {
continue
}
if isColRangeCorCol(filter, path.IdxCols[i]) {
Comment thread
terry1purcell marked this conversation as resolved.
ctx.GetExprCtx().SetSkipPlanCache("Correlated subquery is not cached currently")
access[i-eqOrInCount] = filter
// Range predicates must also remain as post-scan filters
// because the index range is a superset when prefix lengths
// are involved, and to be safe we always keep the filter.
access = access[:i-eqOrInCount+1]
remained = make([]expression.Expression, 0, len(path.TableFilters)-usedCnt)
for k, ok := range used {
if !ok {
remained = append(remained, path.TableFilters[k]) // nozero
}
}
return access, remained
}
}
access = access[:i-eqOrInCount]
break
}
Expand Down Expand Up @@ -307,6 +332,42 @@ func isColEqCorCol(expr expression.Expression, col *expression.Column) bool {
return isColEqExpr(expr, col, isCorCol)
}

// isColRangeCorCol checks if the expression is a range comparison (LT/GT/LE/GE)
// where one side is the given column and the other side is a correlated column.
func isColRangeCorCol(expr expression.Expression, col *expression.Column) bool {
f, ok := expr.(*expression.ScalarFunction)
if !ok {
return false
}
switch f.FuncName.L {
case ast.LT, ast.GT, ast.LE, ast.GE:
default:
return false
}
_, collation := f.CharsetAndCollation()
if c, ok := f.GetArgs()[0].(*expression.Column); ok {
if c.RetType.EvalType() == types.ETString && !collate.CompatibleCollate(collation, c.RetType.GetCollate()) {
return false
}
if _, ok := f.GetArgs()[1].(*expression.CorrelatedColumn); ok {
if col.EqualColumn(c) {
return true
}
}
}
if c, ok := f.GetArgs()[1].(*expression.Column); ok {
if c.RetType.EvalType() == types.ETString && !collate.CompatibleCollate(collation, c.RetType.GetCollate()) {
return false
}
if _, ok := f.GetArgs()[0].(*expression.CorrelatedColumn); ok {
if col.EqualColumn(c) {
return true
}
}
}
return false
}

// isColEqExpr checks if the expression is eq function that one side is column and the other side passes checkFn.
func isColEqExpr(expr expression.Expression, col *expression.Column, checkFn func(expression.Expression) bool) bool {
f, ok := expr.(*expression.ScalarFunction)
Expand Down
Loading