From f9ca5f42cee7235f495df3eada39f7f675a9a6e4 Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Fri, 4 Sep 2026 11:42:13 +0900
Subject: [PATCH v1] Fix duplicate qual clauses in parameterized paths

When outer-join identity 3 permits a join to commute with lower outer
joins, we generate multiple clone versions of its join clause, of
which only one should be applied in any given plan.  When building a
join relation's restriction list, subbuild_joinrel_restrictlist
selects the appropriate clone by checking required_relids and
incompatible_relids, but no such selection was made for movable join
clauses pushed down into a parameterized path.  As a result, a
parameterized scan or join could enforce more than one clone of the
same condition, shown by EXPLAIN as a duplicate qual.  This wastes
effort evaluating the same condition repeatedly.  What is worse, it
applies the clause's selectivity multiple times, underestimating the
result's row count.

To fix, in get_baserel_parampathinfo and get_joinrel_parampathinfo,
skip a clone clause if any outer join listed in its
incompatible_relids has already been computed below the point of
evaluation.
---
 src/backend/optimizer/util/relnode.c | 14 ++++++++
 src/include/nodes/pathnodes.h        |  2 +-
 src/test/regress/expected/join.out   | 51 ++++++++++++++++++++++++++--
 src/test/regress/sql/join.sql        | 22 ++++++++++++
 4 files changed, 85 insertions(+), 4 deletions(-)

diff --git a/src/backend/optimizer/util/relnode.c b/src/backend/optimizer/util/relnode.c
index ee69f81945f..8862004cbad 100644
--- a/src/backend/optimizer/util/relnode.c
+++ b/src/backend/optimizer/util/relnode.c
@@ -1760,6 +1760,15 @@ get_baserel_parampathinfo(PlannerInfo *root, RelOptInfo *baserel,
 	{
 		RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
 
+		/*
+		 * A clone clause must not be enforced here if an outer join it is
+		 * incompatible with has already been computed below the point of
+		 * evaluation; some other clone is the right one to apply.
+		 */
+		if ((rinfo->has_clone || rinfo->is_clone) &&
+			bms_overlap(rinfo->incompatible_relids, joinrelids))
+			continue;
+
 		if (join_clause_is_movable_into(rinfo,
 										baserel->relids,
 										joinrelids))
@@ -1892,6 +1901,11 @@ get_joinrel_parampathinfo(PlannerInfo *root, RelOptInfo *joinrel,
 	{
 		RestrictInfo *rinfo = (RestrictInfo *) lfirst(lc);
 
+		/* As above, reject clones incompatible with a computed outer join */
+		if ((rinfo->has_clone || rinfo->is_clone) &&
+			bms_overlap(rinfo->incompatible_relids, join_and_req))
+			continue;
+
 		if (join_clause_is_movable_into(rinfo,
 										joinrel->relids,
 										join_and_req) &&
diff --git a/src/include/nodes/pathnodes.h b/src/include/nodes/pathnodes.h
index c48e656ce80..460c4f4d8dc 100644
--- a/src/include/nodes/pathnodes.h
+++ b/src/include/nodes/pathnodes.h
@@ -2823,7 +2823,7 @@ typedef struct LimitPath
  * clause and null Vars that it uses.  In practice we only bother to populate
  * it for "clone" clauses, as it's currently only needed to prevent multiple
  * clones of the same clause from being accepted for evaluation at the same
- * join level.
+ * plan level, whether at a join or within a parameterized path.
  *
  * There is also an outer_relids field, which is NULL except for outer join
  * clauses; for those, it is the set of relids on the outer side of the
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 6a85bb246ef..cee3a7b1f22 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -6664,8 +6664,8 @@ select count(*) from int4_tbl t1 left join
   left join lateral
     (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
     on t2.bx = t3.cnt;
-                               QUERY PLAN                                
--------------------------------------------------------------------------
+                         QUERY PLAN                         
+------------------------------------------------------------
  Aggregate
    ->  Nested Loop Left Join
          ->  Seq Scan on int4_tbl t1
@@ -6677,7 +6677,7 @@ select count(*) from int4_tbl t1 left join
                            ->  Hash
                                  ->  Seq Scan on int8_tbl b
                      ->  Subquery Scan on t3
-                           Filter: ((b.q1 = t3.cnt) AND (b.q1 = t3.cnt))
+                           Filter: (b.q1 = t3.cnt)
                            ->  Seq Scan on int4_tbl c
                                  Filter: (f1 = (1))
 (14 rows)
@@ -6693,6 +6693,51 @@ select count(*) from int4_tbl t1 left join
     25
 (1 row)
 
+--
+-- check that a cloned outer-join qual is not enforced multiple times when
+-- it is moved into a parameterized join
+--
+explain (costs off)
+select count(*) from int4_tbl t1 left join
+  (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+    on true
+  left join
+    (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+     join int4_tbl t4 on t3.cnt = t4.f1)
+    on t2.bx = t3.cnt + t4.f1;
+                           QUERY PLAN                           
+----------------------------------------------------------------
+ Aggregate
+   ->  Nested Loop Left Join
+         ->  Seq Scan on int4_tbl t1
+         ->  Materialize
+               ->  Nested Loop Left Join
+                     ->  Hash Left Join
+                           Hash Cond: (a.f1 = b.q2)
+                           ->  Seq Scan on int4_tbl a
+                           ->  Hash
+                                 ->  Seq Scan on int8_tbl b
+                     ->  Hash Join
+                           Hash Cond: (t4.f1 = c.f1)
+                           Join Filter: (b.q1 = (c.f1 + t4.f1))
+                           ->  Seq Scan on int4_tbl t4
+                           ->  Hash
+                                 ->  Seq Scan on int4_tbl c
+                                       Filter: (f1 = (1))
+(17 rows)
+
+select count(*) from int4_tbl t1 left join
+  (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+    on true
+  left join
+    (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+     join int4_tbl t4 on t3.cnt = t4.f1)
+    on t2.bx = t3.cnt + t4.f1;
+ count 
+-------
+    25
+(1 row)
+
 --
 -- test successful handling of full join underneath left join (bug #14105)
 --
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index c541321e6a2..b353073f21e 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -2402,6 +2402,28 @@ select count(*) from int4_tbl t1 left join
     (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
     on t2.bx = t3.cnt;
 
+--
+-- check that a cloned outer-join qual is not enforced multiple times when
+-- it is moved into a parameterized join
+--
+
+explain (costs off)
+select count(*) from int4_tbl t1 left join
+  (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+    on true
+  left join
+    (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+     join int4_tbl t4 on t3.cnt = t4.f1)
+    on t2.bx = t3.cnt + t4.f1;
+
+select count(*) from int4_tbl t1 left join
+  (select b.q1 as bx, 1 as one from int4_tbl a left join int8_tbl b on a.f1 = b.q2) t2
+    on true
+  left join
+    (lateral (select c.f1 as cnt from int4_tbl c where c.f1 = t2.one offset 0) t3
+     join int4_tbl t4 on t3.cnt = t4.f1)
+    on t2.bx = t3.cnt + t4.f1;
+
 --
 -- test successful handling of full join underneath left join (bug #14105)
 --
-- 
2.37.1 (Apple Git-137.1)

