From b2d49688fe84032b9807e0505f56246f9d803467 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas.vondra@postgresql.org>
Date: Sat, 7 Nov 2026 21:27:50 +0100
Subject: [PATCH v2-19] Fix constant propagation through FULL JOIN clauses

Given a FULL JOIN clause "LEFTVAR = RIGHTVAR" and an EquivalenceClass
containing COALESCE(LEFTVAR, RIGHTVAR) and a constant,
reconsider_full_join_clause() derives "LEFTVAR = CONSTANT" and "RIGHTVAR
= CONSTANT" and discards the join clause and the COALESCE condition. It
assumed LEFTVAR comes from the join's lefthand input and RIGHTVAR from
its righthand input, which holds for FULL JOIN USING but not necessarily
for a user-written ON clause.

With the operands reversed, the derived equalities got the wrong
JoinDomains. That could cause an assertion failure or, in non-assert
builds, incorrect results, due to a derived qual being applied to the
wrong side of the join. With both operands from the same input, or one
referencing both inputs, nothing can be deduced, yet bogus equalities
were derived anyway. Both could produce wrong results.

Fix by determining which input each operand comes from, and giving up
unless they come from different inputs. A FULL JOIN whose only condition
is a clause of the latter kind now fails with "FULL JOIN is only
supported with merge-joinable or hash-joinable join conditions", as it
would without the COALESCE condition.

The JoinDomain problem was introduced in v16. The other one (bogus
equalities derived from a qual with both operands from the same input,
or one referencing both inputs) exists in all supported branches, but
before outer-join-aware Vars (v16) only an operand referencing both
inputs can trigger it.

Backpatch to all supported versions. The 15 branch needs only the part
addressing the second issue.

Author: Tomas Vondra <tomas@vondra.me>
Discussion: https://postgr.es/m/92808d26-1b4a-48fe-82f0-597da3ff471f@vondra.me
Backpatch-through: 15
---
 src/backend/optimizer/path/equivclass.c |  36 +++-
 src/backend/optimizer/plan/initsplan.c  |   7 +-
 src/test/regress/expected/join.out      | 211 ++++++++++++++++++++++++
 src/test/regress/sql/join.sql           |  83 ++++++++++
 4 files changed, 326 insertions(+), 11 deletions(-)

diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index fe1786ed61a..f53a2341c55 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -2445,6 +2445,8 @@ reconsider_full_join_clause(PlannerInfo *root, OuterJoinClauseInfo *ojcinfo)
 				right_type;
 	Relids		left_relids,
 				right_relids;
+	JoinDomain *left_jdomain,
+			   *right_jdomain;
 	ListCell   *lc1;
 
 	/* Extract needed info from the clause */
@@ -2457,6 +2459,29 @@ reconsider_full_join_clause(PlannerInfo *root, OuterJoinClauseInfo *ojcinfo)
 	left_relids = rinfo->left_relids;
 	right_relids = rinfo->right_relids;
 
+	/*
+	 * Identify the child JoinDomains within which LEFTVAR = CONSTANT and
+	 * RIGHTVAR = CONSTANT would hold.  We mustn't assume that LEFTVAR comes
+	 * from the join's lefthand input, since the clause might have been
+	 * written as, say, "a FULL JOIN b ON b.y = a.x".  If the clause doesn't
+	 * relate an expression of one input to an expression of the other, we
+	 * can't deduce anything from it.
+	 */
+	if (bms_is_subset(left_relids, sjinfo->syn_lefthand) &&
+		bms_is_subset(right_relids, sjinfo->syn_righthand))
+	{
+		left_jdomain = find_join_domain(root, sjinfo->syn_lefthand);
+		right_jdomain = find_join_domain(root, sjinfo->syn_righthand);
+	}
+	else if (bms_is_subset(left_relids, sjinfo->syn_righthand) &&
+			 bms_is_subset(right_relids, sjinfo->syn_lefthand))
+	{
+		left_jdomain = find_join_domain(root, sjinfo->syn_righthand);
+		right_jdomain = find_join_domain(root, sjinfo->syn_lefthand);
+	}
+	else
+		return false;
+
 	foreach(lc1, root->eq_classes)
 	{
 		EquivalenceClass *cur_ec = (EquivalenceClass *) lfirst(lc1);
@@ -2548,7 +2573,6 @@ reconsider_full_join_clause(PlannerInfo *root, OuterJoinClauseInfo *ojcinfo)
 			EquivalenceMember *cur_em = (EquivalenceMember *) lfirst(lc2);
 			Oid			eq_op;
 			RestrictInfo *newrinfo;
-			JoinDomain *jdomain;
 
 			if (!cur_em->em_is_const)
 				continue;		/* ignore non-const members */
@@ -2564,9 +2588,8 @@ reconsider_full_join_clause(PlannerInfo *root, OuterJoinClauseInfo *ojcinfo)
 													   cur_em->em_expr,
 													   bms_copy(left_relids),
 													   cur_ec->ec_min_security);
-				/* This equality holds within the lefthand child JoinDomain */
-				jdomain = find_join_domain(root, sjinfo->syn_lefthand);
-				if (process_equivalence(root, &newrinfo, jdomain))
+				/* This equality holds within LEFTVAR's child JoinDomain */
+				if (process_equivalence(root, &newrinfo, left_jdomain))
 					matchleft = true;
 			}
 			eq_op = select_equality_operator(cur_ec,
@@ -2581,9 +2604,8 @@ reconsider_full_join_clause(PlannerInfo *root, OuterJoinClauseInfo *ojcinfo)
 													   cur_em->em_expr,
 													   bms_copy(right_relids),
 													   cur_ec->ec_min_security);
-				/* This equality holds within the righthand child JoinDomain */
-				jdomain = find_join_domain(root, sjinfo->syn_righthand);
-				if (process_equivalence(root, &newrinfo, jdomain))
+				/* This equality holds within RIGHTVAR's child JoinDomain */
+				if (process_equivalence(root, &newrinfo, right_jdomain))
 					matchright = true;
 			}
 		}
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index 78d708a7041..25e0e6ee106 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -3164,10 +3164,9 @@ distribute_qual_to_rels(PlannerInfo *root, Node *clause,
 	 *
 	 * If it is a full outer-join qualification, we add it to
 	 * root->full_join_clauses.  (Ideally we'd discard cases that aren't
-	 * leftvar = rightvar, as we do for left/right joins, but this routine
-	 * doesn't have the info needed to do that; and the current usage of the
-	 * full_join_clauses list doesn't require that, so it's not currently
-	 * worth complicating this routine's API to make it possible.)
+	 * leftvar = rightvar, as we do for left/right joins, but we leave that to
+	 * reconsider_full_join_clause(), which has to work out which side of the
+	 * join each operand comes from anyway.)
 	 *
 	 * If none of the above hold, pass it off to
 	 * distribute_restrictinfo_to_rels().
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index bc0d4ca8a71..a6a49c823ae 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -5783,6 +5783,217 @@ select * from int8_tbl t1 left join int8_tbl t2 on t1.q2 = t2.q1,
  4567890123456789 | 4567890123456789 | 4567890123456789 | 4567890123456789 | 4567890123456789 | -4567890123456789
 (10 rows)
 
+--
+-- check deductions from FULL JOIN clauses and COALESCE() = constant: the
+-- join clause's operands may appear in either order, but must come from
+-- different inputs of the join
+--
+-- operands in reverse order: constants are still deduced for both inputs
+explain (costs off)
+select * from int4_tbl a full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+             QUERY PLAN             
+------------------------------------
+ Merge Full Join
+   ->  Seq Scan on int4_tbl a
+         Filter: (f1 = 0)
+   ->  Materialize
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(6 rows)
+
+select * from int4_tbl a full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+ f1 | f1 
+----+----
+  0 |  0
+(1 row)
+
+-- ... also when the left input is a join
+explain (costs off)
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+             QUERY PLAN             
+------------------------------------
+ Merge Full Join
+   ->  Nested Loop
+         ->  Seq Scan on int4_tbl a
+               Filter: (f1 = 0)
+         ->  Seq Scan on int4_tbl b
+               Filter: (f1 = 0)
+   ->  Materialize
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(9 rows)
+
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+ f1 | f1 | f1 
+----+----+----
+  0 |  0 |  0
+(1 row)
+
+-- the gating qual must be applied to the join's left input
+explain (costs off)
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1 and a.f1 = (select 1))
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Merge Full Join
+   InitPlan expr_1
+     ->  Result
+   ->  Result
+         One-Time Filter: ((InitPlan expr_1).col1 = 0)
+         ->  Nested Loop
+               ->  Seq Scan on int4_tbl a
+                     Filter: (f1 = 0)
+               ->  Seq Scan on int4_tbl b
+                     Filter: (f1 = 0)
+   ->  Materialize
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(13 rows)
+
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1 and a.f1 = (select 1))
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+ f1 | f1 | f1 
+----+----+----
+    |    |  0
+(1 row)
+
+-- an operand may be nulled by a lower outer join, in either order
+explain (costs off)
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on b.f1 = c.f1
+  where coalesce(b.f1, c.f1) = 0;
+                QUERY PLAN                
+------------------------------------------
+ Merge Full Join
+   ->  Hash Left Join
+         Hash Cond: (a.f1 = b.f1)
+         Filter: (b.f1 = 0)
+         ->  Seq Scan on int4_tbl a
+         ->  Hash
+               ->  Seq Scan on int4_tbl b
+   ->  Materialize
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(10 rows)
+
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on b.f1 = c.f1
+  where coalesce(b.f1, c.f1) = 0;
+ f1 | f1 | f1 
+----+----+----
+  0 |  0 |  0
+(1 row)
+
+explain (costs off)
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = b.f1
+  where coalesce(c.f1, b.f1) = 0;
+                QUERY PLAN                
+------------------------------------------
+ Merge Full Join
+   ->  Hash Left Join
+         Hash Cond: (a.f1 = b.f1)
+         Filter: (b.f1 = 0)
+         ->  Seq Scan on int4_tbl a
+         ->  Hash
+               ->  Seq Scan on int4_tbl b
+   ->  Materialize
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(10 rows)
+
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = b.f1
+  where coalesce(c.f1, b.f1) = 0;
+ f1 | f1 | f1 
+----+----+----
+  0 |  0 |  0
+(1 row)
+
+-- nothing can be deduced from a clause relating two vars of the same input
+explain (costs off)
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1, b.f1) = 0
+  order by b.f1;
+                   QUERY PLAN                   
+------------------------------------------------
+ Sort
+   Sort Key: b.f1
+   ->  Hash Full Join
+         Hash Cond: (a.f1 = c.f1)
+         Join Filter: (a.f1 = b.f1)
+         Filter: (COALESCE(a.f1, b.f1) = 0)
+         ->  Nested Loop
+               ->  Seq Scan on int4_tbl a
+               ->  Materialize
+                     ->  Seq Scan on int4_tbl b
+         ->  Hash
+               ->  Seq Scan on int4_tbl c
+(12 rows)
+
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1, b.f1) = 0
+  order by b.f1;
+ f1 |     f1      | f1 
+----+-------------+----
+  0 | -2147483647 |   
+  0 |     -123456 |   
+  0 |           0 |  0
+  0 |      123456 |   
+  0 |  2147483647 |   
+(5 rows)
+
+-- ... nor from a clause with an operand that references both inputs
+explain (costs off)
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 - c.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1 - c.f1, b.f1) = 123456
+  order by a.f1;
+                        QUERY PLAN                        
+----------------------------------------------------------
+ Sort
+   Sort Key: a.f1
+   ->  Hash Full Join
+         Hash Cond: (a.f1 = c.f1)
+         Join Filter: ((a.f1 - c.f1) = b.f1)
+         Filter: (COALESCE((a.f1 - c.f1), b.f1) = 123456)
+         ->  Nested Loop
+               ->  Seq Scan on int4_tbl a
+               ->  Materialize
+                     ->  Seq Scan on int4_tbl b
+         ->  Hash
+               ->  Seq Scan on int4_tbl c
+(12 rows)
+
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 - c.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1 - c.f1, b.f1) = 123456
+  order by a.f1;
+     f1      |   f1   | f1 
+-------------+--------+----
+ -2147483647 | 123456 |   
+     -123456 | 123456 |   
+           0 | 123456 |   
+      123456 | 123456 |   
+  2147483647 | 123456 |   
+(5 rows)
+
+-- if the only join condition relates two vars of the same input, the FULL
+-- JOIN can't be planned, just as without the WHERE condition
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 = b.f1
+  where coalesce(a.f1, b.f1) = 0;
+ERROR:  FULL JOIN is only supported with merge-joinable or hash-joinable join conditions
 --
 -- check handling of join aliases when flattening multiple levels of subquery
 --
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 1f9895d8e87..f520fdae997 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -1989,6 +1989,89 @@ select * from int8_tbl t1 left join int8_tbl t2 on t1.q2 = t2.q1,
 select * from int8_tbl t1 left join int8_tbl t2 on t1.q2 = t2.q1,
   lateral (select * from int8_tbl t3 where t2.q1 = t2.q2) ss;
 
+--
+-- check deductions from FULL JOIN clauses and COALESCE() = constant: the
+-- join clause's operands may appear in either order, but must come from
+-- different inputs of the join
+--
+
+-- operands in reverse order: constants are still deduced for both inputs
+explain (costs off)
+select * from int4_tbl a full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+
+select * from int4_tbl a full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+
+-- ... also when the left input is a join
+explain (costs off)
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+
+-- the gating qual must be applied to the join's left input
+explain (costs off)
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1 and a.f1 = (select 1))
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+
+select * from (int4_tbl a join int4_tbl b on a.f1 = b.f1 and a.f1 = (select 1))
+  full join int4_tbl c on c.f1 = a.f1
+  where coalesce(c.f1, a.f1) = 0;
+
+-- an operand may be nulled by a lower outer join, in either order
+explain (costs off)
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on b.f1 = c.f1
+  where coalesce(b.f1, c.f1) = 0;
+
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on b.f1 = c.f1
+  where coalesce(b.f1, c.f1) = 0;
+
+explain (costs off)
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = b.f1
+  where coalesce(c.f1, b.f1) = 0;
+
+select * from (int4_tbl a left join int4_tbl b on a.f1 = b.f1)
+  full join int4_tbl c on c.f1 = b.f1
+  where coalesce(c.f1, b.f1) = 0;
+
+-- nothing can be deduced from a clause relating two vars of the same input
+explain (costs off)
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1, b.f1) = 0
+  order by b.f1;
+
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1, b.f1) = 0
+  order by b.f1;
+
+-- ... nor from a clause with an operand that references both inputs
+explain (costs off)
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 - c.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1 - c.f1, b.f1) = 123456
+  order by a.f1;
+
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 - c.f1 = b.f1 and a.f1 = c.f1
+  where coalesce(a.f1 - c.f1, b.f1) = 123456
+  order by a.f1;
+
+-- if the only join condition relates two vars of the same input, the FULL
+-- JOIN can't be planned, just as without the WHERE condition
+select * from (int4_tbl a cross join int4_tbl b)
+  full join int4_tbl c on a.f1 = b.f1
+  where coalesce(a.f1, b.f1) = 0;
+
 --
 -- check handling of join aliases when flattening multiple levels of subquery
 --
-- 
2.55.0

