From 8f7ac6e8676ba25e41dcff6d28e28d06a8acd1a4 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Sat, 31 Oct 2026 18:09:54 -0400
Subject: [PATCH vmaster] Fix constant propagation through FULL JOIN clauses

reconsider_full_join_clause() looks for a mergejoinable FULL JOIN clause
"LEFTVAR = RIGHTVAR" and an EquivalenceClass containing both a constant
and COALESCE(LEFTVAR, RIGHTVAR). If it finds both, it derives clauses
"LEFTVAR = CONSTANT" and "RIGHTVAR = CONSTANT", which can be pushed down
into the join's inputs. It then replaces the join clause with constant
TRUE and removes the COALESCE member from the EC, so neither the join
clause nor the WHERE condition is checked anymore.

That's valid only if LEFTVAR and RIGHTVAR come from different inputs
of the join, but the code neither checked that nor worked out which
input each operand comes from. It assumed that the left operand belongs
to the join's lefthand input and the right operand to its righthand
input. That's true for the clauses generated for FULL JOIN USING, but
not necessarily for a user-written ON clause. The COALESCE matching
doesn't ensure it either. This causes two separate problems.

The operands may be written in the "wrong" order, in which case each
equality gets the other input's JoinDomain. In assert-enabled builds
that triggers an assertion in process_implied_equality().

In non-assert builds, the bogus JoinDomain is usually harmless, since
derived clauses containing Vars are placed according to their own
relids. But a variable-free clause, which is derived if the EC
contains another pseudoconstant such as a Param, is applied as a
gating qual at the level indicated by the JoinDomain, i.e. on the
wrong side of the full join. That may cause the query to produce
incorrect results.

A second issue is that nothing can be deduced from a clause that doesn't
relate an expression of one input to an expression of the other, i.e. if
both operands come from the same input, or if an operand references both
inputs. The code however derived bogus equalities from such clauses
anyway, and discarded the join clause and the WHERE condition. Again,
this may produce incorrect query results.

Fix by working out which input each operand belongs to, using the
SpecialJoinInfo's syn_lefthand and syn_righthand, and giving up unless
the operands come from different inputs. Each derived equality is then
registered with the JoinDomain of the input its operand comes from.

Also adjust the comment in distribute_qual_to_rels() that claimed
nothing needs full_join_clauses entries to be of the form
leftvar = rightvar.

A query whose FULL JOIN condition consists of nothing but a clause of
the second kind now fails with "FULL JOIN is only supported with
merge-joinable or hash-joinable join conditions", as it already did
without the WHERE condition, instead of returning wrong results.

Add regression tests with operands in reversed order (with a gating
qual), operands from the same input, and an operand referencing both
inputs.

The JoinDomain problem was introduced in v16. The other problem exists
in all supported branches, but before outer-join-aware Vars were
introduced in 16 (commit 2489d76c4), it was harder to hit: a COALESCE()
above a FULL JOIN couldn't become part of an EC unless it referenced all
rels of both join inputs (see check_equivalence_delay()). So only an
operand referencing both inputs can trigger it there.
---
 src/backend/optimizer/path/equivclass.c |  36 ++++++--
 src/backend/optimizer/plan/initsplan.c  |   7 +-
 src/test/regress/expected/join.out      | 105 ++++++++++++++++++++++++
 src/test/regress/sql/join.sql           |  40 +++++++++
 4 files changed, 177 insertions(+), 11 deletions(-)

diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index 598b62b6764..439cb651c01 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -2444,6 +2444,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 */
@@ -2456,6 +2458,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);
@@ -2547,7 +2572,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 */
@@ -2563,9 +2587,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,
@@ -2580,9 +2603,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 cec76342f6e..9f8d51eb372 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -3176,10 +3176,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 4ec9291833b..d6226701739 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -6556,6 +6556,111 @@ 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
+--
+-- 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)
+
+-- 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)
+
 --
 -- 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 381c57a5a88..14f7c69df3d 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -2278,6 +2278,46 @@ 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
+--
+
+-- 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;
+
+-- 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;
+
 --
 -- check handling of join aliases when flattening multiple levels of subquery
 --
-- 
2.55.0

