From 0789cff379578515768b746e1bb8c58c75be19f6 Mon Sep 17 00:00:00 2001 From: Jianghua Yang Date: Tue, 30 Jun 2026 06:42:31 +0800 Subject: [PATCH 1/3] ORCA: don't push LOJ ON-pred onto its own outer in PushThruOuterChild When CNormalizer::PushThruOuterChild is invoked from PushThruSelect with a predicate that happens to be (or contain) the LOJ's own ON predicate, the SplitConjunct / FPushable structural check would classify it as pushable to the outer relation -- because FPushable only verifies that the predicate's columns are a subset of the outer's output columns, and an LOJ ON-pred that references only outer-side columns trivially satisfies that. The consequence: the ON-pred is duplicated as a scan filter on the outer relation, which silently violates LOJ semantics. Outer rows that don't satisfy the ON-pred (and would have been null-padded in the result) get discarded at the scan, so a downstream "WHERE inner.col IS NULL" can no longer match them. Trigger pattern: two or more chained LEFT JOINs whose ON-clauses use the same boolean column from the outer relation, with a WHERE on top. Fix: at the entry of PushThruOuterChild, strip from the incoming conjunct any conjunct that structurally matches a conjunct of the LOJ's own ON predicate before handing it to SplitConjunct. The stripped conjunct is already enforced by the join itself for matching rows and must not become a filter on the outer for non-matching rows. Add a minimal regression case to bfv_joins covering the two-LOJ-on-same- boolean-column pattern with a WHERE on the inner side. --- .../libgpopt/src/operators/CNormalizer.cpp | 54 ++++++++++++++++++- src/test/regress/expected/bfv_joins.out | 30 +++++++++++ .../regress/expected/bfv_joins_optimizer.out | 30 +++++++++++ src/test/regress/sql/bfv_joins.sql | 20 +++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/backend/gporca/libgpopt/src/operators/CNormalizer.cpp b/src/backend/gporca/libgpopt/src/operators/CNormalizer.cpp index 38c8a93a9ab..1a650a3831f 100644 --- a/src/backend/gporca/libgpopt/src/operators/CNormalizer.cpp +++ b/src/backend/gporca/libgpopt/src/operators/CNormalizer.cpp @@ -259,9 +259,60 @@ CNormalizer::PushThruOuterChild(CMemoryPool *mp, CExpression *pexpr, CExpression *pexprInner = (*pexpr)[1]; CExpression *pexprPred = (*pexpr)[2]; + // Strip from the incoming conjunct any conjunct that structurally matches + // a conjunct of the LOJ's own ON predicate. Pushing such a conjunct onto + // the LOJ's outer would duplicate the ON pred as a scan filter on the + // outer relation, which is invalid for LOJ semantics: outer rows that + // don't satisfy the ON pred must still appear in the result (null-padded), + // so they must not be filtered out below the join. + CExpression *pexprConjEffective = pexprConj; + CExpression *pexprConjOwned = nullptr; + { + CExpressionArray *pdrgpexprConjAll = + CPredicateUtils::PdrgpexprConjuncts(mp, pexprConj); + CExpressionArray *pdrgpexprOnConj = + CPredicateUtils::PdrgpexprConjuncts(mp, pexprPred); + CExpressionArray *pdrgpexprFiltered = + GPOS_NEW(mp) CExpressionArray(mp); + BOOL fAnyStripped = false; + for (ULONG ul = 0; ul < pdrgpexprConjAll->Size(); ul++) + { + CExpression *pexprC = (*pdrgpexprConjAll)[ul]; + BOOL fMatchesOn = false; + for (ULONG uo = 0; uo < pdrgpexprOnConj->Size(); uo++) + { + if (pexprC->Matches((*pdrgpexprOnConj)[uo])) + { + fMatchesOn = true; + break; + } + } + if (fMatchesOn) + { + fAnyStripped = true; + continue; + } + pexprC->AddRef(); + pdrgpexprFiltered->Append(pexprC); + } + pdrgpexprConjAll->Release(); + pdrgpexprOnConj->Release(); + + if (fAnyStripped) + { + pexprConjOwned = + CPredicateUtils::PexprConjunction(mp, pdrgpexprFiltered); + pexprConjEffective = pexprConjOwned; + } + else + { + pdrgpexprFiltered->Release(); + } + } + CExpressionArray *pdrgpexprPushable = nullptr; CExpressionArray *pdrgpexprUnpushable = nullptr; - SplitConjunct(mp, pexprOuter, pexprConj, &pdrgpexprPushable, + SplitConjunct(mp, pexprOuter, pexprConjEffective, &pdrgpexprPushable, &pdrgpexprUnpushable); if (0 < pdrgpexprPushable->Size()) @@ -323,6 +374,7 @@ CNormalizer::PushThruOuterChild(CMemoryPool *mp, CExpression *pexpr, pdrgpexprPushable->Release(); pdrgpexprUnpushable->Release(); + CRefCount::SafeRelease(pexprConjOwned); } diff --git a/src/test/regress/expected/bfv_joins.out b/src/test/regress/expected/bfv_joins.out index da6e7481318..ff7947488c8 100644 --- a/src/test/regress/expected/bfv_joins.out +++ b/src/test/regress/expected/bfv_joins.out @@ -4235,6 +4235,36 @@ select (trunc(extract(epoch from now())) - :unix_time1) < 100 is_ok; (1 row) reset optimizer; +-- ORCA bug: a boolean ON-clause of a LEFT JOIN must not be pushed down as a +-- scan filter on the outer relation. When the same outer relation feeds +-- multiple LEFT JOINs whose ON-clauses use the same boolean column AND there +-- is a WHERE on top, the normalizer used to push the ON-pred onto the LOJ's +-- own outer child, discarding outer rows that should be null-padded. +create table loj_bool_x(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y1(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y2(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +insert into loj_bool_x values (true), (false), (false); +insert into loj_bool_y1 values (true); +insert into loj_bool_y2 values (true); +-- Expect 2 rows: the two FALSE rows in loj_bool_x, with NULL from loj_bool_y2. +-- The plan must NOT contain "Filter: c1" on Seq Scan of loj_bool_x. +select loj_bool_x.c1, loj_bool_y2.c1 as y2c1 + from loj_bool_x left join loj_bool_y1 on loj_bool_x.c1 + left join loj_bool_y2 on loj_bool_x.c1 + where loj_bool_y2.c1 is null + order by 1, 2; + c1 | y2c1 +----+------ + f | + f | +(2 rows) + -- Clean up. None of the objects we create are very interesting to keep around. reset search_path; set client_min_messages='warning'; diff --git a/src/test/regress/expected/bfv_joins_optimizer.out b/src/test/regress/expected/bfv_joins_optimizer.out index 934b682492b..d8cb7b7a425 100644 --- a/src/test/regress/expected/bfv_joins_optimizer.out +++ b/src/test/regress/expected/bfv_joins_optimizer.out @@ -4252,6 +4252,36 @@ select (trunc(extract(epoch from now())) - :unix_time1) < 100 is_ok; (1 row) reset optimizer; +-- ORCA bug: a boolean ON-clause of a LEFT JOIN must not be pushed down as a +-- scan filter on the outer relation. When the same outer relation feeds +-- multiple LEFT JOINs whose ON-clauses use the same boolean column AND there +-- is a WHERE on top, the normalizer used to push the ON-pred onto the LOJ's +-- own outer child, discarding outer rows that should be null-padded. +create table loj_bool_x(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y1(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y2(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +insert into loj_bool_x values (true), (false), (false); +insert into loj_bool_y1 values (true); +insert into loj_bool_y2 values (true); +-- Expect 2 rows: the two FALSE rows in loj_bool_x, with NULL from loj_bool_y2. +-- The plan must NOT contain "Filter: c1" on Seq Scan of loj_bool_x. +select loj_bool_x.c1, loj_bool_y2.c1 as y2c1 + from loj_bool_x left join loj_bool_y1 on loj_bool_x.c1 + left join loj_bool_y2 on loj_bool_x.c1 + where loj_bool_y2.c1 is null + order by 1, 2; + c1 | y2c1 +----+------ + f | + f | +(2 rows) + -- Clean up. None of the objects we create are very interesting to keep around. reset search_path; set client_min_messages='warning'; diff --git a/src/test/regress/sql/bfv_joins.sql b/src/test/regress/sql/bfv_joins.sql index 3a0fca09fc7..1dca58051c8 100644 --- a/src/test/regress/sql/bfv_joins.sql +++ b/src/test/regress/sql/bfv_joins.sql @@ -649,6 +649,26 @@ select (trunc(extract(epoch from now())) - :unix_time1) < 100 is_ok; reset optimizer; +-- ORCA bug: a boolean ON-clause of a LEFT JOIN must not be pushed down as a +-- scan filter on the outer relation. When the same outer relation feeds +-- multiple LEFT JOINs whose ON-clauses use the same boolean column AND there +-- is a WHERE on top, the normalizer used to push the ON-pred onto the LOJ's +-- own outer child, discarding outer rows that should be null-padded. +create table loj_bool_x(c1 boolean); +create table loj_bool_y1(c1 boolean); +create table loj_bool_y2(c1 boolean); +insert into loj_bool_x values (true), (false), (false); +insert into loj_bool_y1 values (true); +insert into loj_bool_y2 values (true); + +-- Expect 2 rows: the two FALSE rows in loj_bool_x, with NULL from loj_bool_y2. +-- The plan must NOT contain "Filter: c1" on Seq Scan of loj_bool_x. +select loj_bool_x.c1, loj_bool_y2.c1 as y2c1 + from loj_bool_x left join loj_bool_y1 on loj_bool_x.c1 + left join loj_bool_y2 on loj_bool_x.c1 + where loj_bool_y2.c1 is null + order by 1, 2; + -- Clean up. None of the objects we create are very interesting to keep around. reset search_path; set client_min_messages='warning'; From 1c94f9154fb1177afcc8dbff3c5f01beaa77c759 Mon Sep 17 00:00:00 2001 From: Jianghua Yang Date: Tue, 30 Jun 2026 14:05:55 +0800 Subject: [PATCH 2/3] regress: refresh join_optimizer.out for LOJ ON-pred pushdown fix Side-effect of the previous commit (ORCA: don't push LOJ ON-pred onto its own outer): in this specific test query, ORCA used to push the inner LOJ's ON predicate t2.id=1 down to t2's outer scan as an Index Cond. The outer LOJ's matching ON predicate filtered out the same rows anyway, so the result was unchanged, but the pushdown was a context-dependent optimization that the new conservative rule no longer performs. The new plan is semantically equivalent (same rows; same Optimizer choice tree apart from the missing Index Cond on t2). Refresh the expected output to match. Query under test: select 1 from a t1 left join (a t2 left join a t3 on t2.id = 1) on t2.id = 1; The planner answer file (join.out) is unaffected since the planner takes a different code path and was already emitting a Seq Scan with a scan-level Filter for this case. --- src/test/regress/expected/join_optimizer.out | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/test/regress/expected/join_optimizer.out b/src/test/regress/expected/join_optimizer.out index fd987628b10..25859b2b520 100644 --- a/src/test/regress/expected/join_optimizer.out +++ b/src/test/regress/expected/join_optimizer.out @@ -6435,8 +6435,8 @@ select d.* from d left join (select distinct * from b) s explain (costs off) select 1 from a t1 left join (a t2 left join a t3 on t2.id = 1) on t2.id = 1; - QUERY PLAN --------------------------------------------------------------------------------------- + QUERY PLAN +--------------------------------------------------------------------------------------- Result -> Gather Motion 3:1 (slice1; segments: 3) -> Nested Loop Left Join @@ -6446,13 +6446,12 @@ select 1 from a t1 -> Broadcast Motion 3:3 (slice2; segments: 3) -> Nested Loop Left Join Join Filter: (t2.id = 1) - -> Index Scan using a_pkey on a t2 - Index Cond: (id = 1) + -> Seq Scan on a t2 -> Materialize -> Broadcast Motion 3:3 (slice3; segments: 3) -> Seq Scan on a t3 Optimizer: GPORCA -(15 rows) +(14 rows) -- check join removal works when uniqueness of the join condition is enforced -- by a UNION From 94372e75ffa45bd67cb4553dda4ac59a48e08a60 Mon Sep 17 00:00:00 2001 From: Jianghua Yang Date: Tue, 30 Jun 2026 14:16:16 +0800 Subject: [PATCH 3/3] regress: mirror LOJ ON-pred fix into pax_storage regress suite contrib/pax_storage maintains its own copy of the upstream regress suite. Mirror two changes already applied to src/test/regress: 1. Add the loj_bool_* regression case (two LEFT JOINs sharing a boolean ON-clause + WHERE on the inner side) into bfv_joins.sql + both expected outputs, so the pax_storage CI run also exercises the fix. 2. Refresh join_optimizer.out at the "join removal is not possible here" case: the inner LOJ's t2.id=1 ON-pred is no longer pushed down as Index Cond on t2. Plan is semantically equivalent (same rows; outer LOJ's matching ON-pred filters them anyway) and only loses a context-dependent index pushdown. --- .../src/test/regress/expected/bfv_joins.out | 30 +++++++++++++++++++ .../regress/expected/bfv_joins_optimizer.out | 30 +++++++++++++++++++ .../test/regress/expected/join_optimizer.out | 5 ++-- .../src/test/regress/sql/bfv_joins.sql | 20 +++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/contrib/pax_storage/src/test/regress/expected/bfv_joins.out b/contrib/pax_storage/src/test/regress/expected/bfv_joins.out index 31515680256..e82e9e158c7 100644 --- a/contrib/pax_storage/src/test/regress/expected/bfv_joins.out +++ b/contrib/pax_storage/src/test/regress/expected/bfv_joins.out @@ -4190,6 +4190,36 @@ INSERT INTO ext_stats_tbl VALUES('tC', true); ANALYZE ext_stats_tbl; explain SELECT 1 FROM ext_stats_tbl t11 FULL JOIN ext_stats_tbl t12 ON t12.c2; ERROR: FULL JOIN is only supported with merge-joinable or hash-joinable join conditions +-- ORCA bug: a boolean ON-clause of a LEFT JOIN must not be pushed down as a +-- scan filter on the outer relation. When the same outer relation feeds +-- multiple LEFT JOINs whose ON-clauses use the same boolean column AND there +-- is a WHERE on top, the normalizer used to push the ON-pred onto the LOJ's +-- own outer child, discarding outer rows that should be null-padded. +create table loj_bool_x(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y1(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y2(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +insert into loj_bool_x values (true), (false), (false); +insert into loj_bool_y1 values (true); +insert into loj_bool_y2 values (true); +-- Expect 2 rows: the two FALSE rows in loj_bool_x, with NULL from loj_bool_y2. +-- The plan must NOT contain "Filter: c1" on Seq Scan of loj_bool_x. +select loj_bool_x.c1, loj_bool_y2.c1 as y2c1 + from loj_bool_x left join loj_bool_y1 on loj_bool_x.c1 + left join loj_bool_y2 on loj_bool_x.c1 + where loj_bool_y2.c1 is null + order by 1, 2; + c1 | y2c1 +----+------ + f | + f | +(2 rows) + -- Clean up. None of the objects we create are very interesting to keep around. reset search_path; set client_min_messages='warning'; diff --git a/contrib/pax_storage/src/test/regress/expected/bfv_joins_optimizer.out b/contrib/pax_storage/src/test/regress/expected/bfv_joins_optimizer.out index af48a5dd8d9..6426bf4f8fb 100644 --- a/contrib/pax_storage/src/test/regress/expected/bfv_joins_optimizer.out +++ b/contrib/pax_storage/src/test/regress/expected/bfv_joins_optimizer.out @@ -4215,6 +4215,36 @@ INSERT INTO ext_stats_tbl VALUES('tC', true); ANALYZE ext_stats_tbl; explain SELECT 1 FROM ext_stats_tbl t11 FULL JOIN ext_stats_tbl t12 ON t12.c2; ERROR: FULL JOIN is only supported with merge-joinable or hash-joinable join conditions +-- ORCA bug: a boolean ON-clause of a LEFT JOIN must not be pushed down as a +-- scan filter on the outer relation. When the same outer relation feeds +-- multiple LEFT JOINs whose ON-clauses use the same boolean column AND there +-- is a WHERE on top, the normalizer used to push the ON-pred onto the LOJ's +-- own outer child, discarding outer rows that should be null-padded. +create table loj_bool_x(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y1(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +create table loj_bool_y2(c1 boolean); +NOTICE: Table doesn't have 'DISTRIBUTED BY' clause -- Using column named 'c1' as the Apache Cloudberry data distribution key for this table. +HINT: The 'DISTRIBUTED BY' clause determines the distribution of data. Make sure column(s) chosen are the optimal data distribution key to minimize skew. +insert into loj_bool_x values (true), (false), (false); +insert into loj_bool_y1 values (true); +insert into loj_bool_y2 values (true); +-- Expect 2 rows: the two FALSE rows in loj_bool_x, with NULL from loj_bool_y2. +-- The plan must NOT contain "Filter: c1" on Seq Scan of loj_bool_x. +select loj_bool_x.c1, loj_bool_y2.c1 as y2c1 + from loj_bool_x left join loj_bool_y1 on loj_bool_x.c1 + left join loj_bool_y2 on loj_bool_x.c1 + where loj_bool_y2.c1 is null + order by 1, 2; + c1 | y2c1 +----+------ + f | + f | +(2 rows) + -- Clean up. None of the objects we create are very interesting to keep around. reset search_path; set client_min_messages='warning'; diff --git a/contrib/pax_storage/src/test/regress/expected/join_optimizer.out b/contrib/pax_storage/src/test/regress/expected/join_optimizer.out index 8dc204ae381..7b87831fce5 100644 --- a/contrib/pax_storage/src/test/regress/expected/join_optimizer.out +++ b/contrib/pax_storage/src/test/regress/expected/join_optimizer.out @@ -6473,13 +6473,12 @@ select 1 from a t1 -> Broadcast Motion 3:3 (slice2; segments: 3) -> Nested Loop Left Join Join Filter: (t2.id = 1) - -> Index Only Scan using a_pkey on a t2 - Index Cond: (id = 1) + -> Seq Scan on a t2 -> Materialize -> Broadcast Motion 3:3 (slice3; segments: 3) -> Seq Scan on a t3 Optimizer: GPORCA -(15 rows) +(14 rows) -- check join removal works when uniqueness of the join condition is enforced -- by a UNION diff --git a/contrib/pax_storage/src/test/regress/sql/bfv_joins.sql b/contrib/pax_storage/src/test/regress/sql/bfv_joins.sql index edc39f58a7d..cb4acd0a9c6 100644 --- a/contrib/pax_storage/src/test/regress/sql/bfv_joins.sql +++ b/contrib/pax_storage/src/test/regress/sql/bfv_joins.sql @@ -604,6 +604,26 @@ ANALYZE ext_stats_tbl; explain SELECT 1 FROM ext_stats_tbl t11 FULL JOIN ext_stats_tbl t12 ON t12.c2; +-- ORCA bug: a boolean ON-clause of a LEFT JOIN must not be pushed down as a +-- scan filter on the outer relation. When the same outer relation feeds +-- multiple LEFT JOINs whose ON-clauses use the same boolean column AND there +-- is a WHERE on top, the normalizer used to push the ON-pred onto the LOJ's +-- own outer child, discarding outer rows that should be null-padded. +create table loj_bool_x(c1 boolean); +create table loj_bool_y1(c1 boolean); +create table loj_bool_y2(c1 boolean); +insert into loj_bool_x values (true), (false), (false); +insert into loj_bool_y1 values (true); +insert into loj_bool_y2 values (true); + +-- Expect 2 rows: the two FALSE rows in loj_bool_x, with NULL from loj_bool_y2. +-- The plan must NOT contain "Filter: c1" on Seq Scan of loj_bool_x. +select loj_bool_x.c1, loj_bool_y2.c1 as y2c1 + from loj_bool_x left join loj_bool_y1 on loj_bool_x.c1 + left join loj_bool_y2 on loj_bool_x.c1 + where loj_bool_y2.c1 is null + order by 1, 2; + -- Clean up. None of the objects we create are very interesting to keep around. reset search_path; set client_min_messages='warning';