From 7fbef5ee25560f4bcadde39840904ff0f30068ea 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-15] 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 | 37 +++++++++++++
 src/backend/optimizer/plan/initsplan.c  |  6 +--
 src/test/regress/expected/join.out      | 69 +++++++++++++++++++++++++
 src/test/regress/sql/join.sql           | 33 ++++++++++++
 4 files changed, 142 insertions(+), 3 deletions(-)

diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index 0341cf03663..3359a668881 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -2200,6 +2200,7 @@ reconsider_full_join_clause(PlannerInfo *root, RestrictInfo *rinfo)
 				right_relids,
 				left_nullable_relids,
 				right_nullable_relids;
+	SpecialJoinInfo *sjinfo = NULL;
 	ListCell   *lc1;
 
 	/* Can't use an outerjoin_delayed clause here */
@@ -2220,6 +2221,42 @@ reconsider_full_join_clause(PlannerInfo *root, RestrictInfo *rinfo)
 	right_nullable_relids = bms_intersect(right_relids,
 										  rinfo->nullable_relids);
 
+	/*
+	 * Find the SpecialJoinInfo of the FULL JOIN the clause belongs to.  The
+	 * outer_relids of a FULL JOIN clause are all the base rels syntactically
+	 * within the join (see distribute_qual_to_rels), which identifies the
+	 * join uniquely.
+	 */
+	foreach(lc1, root->join_info_list)
+	{
+		SpecialJoinInfo *cur_sjinfo = (SpecialJoinInfo *) lfirst(lc1);
+
+		if (cur_sjinfo->jointype == JOIN_FULL &&
+			bms_equal(rinfo->outer_relids,
+					  bms_union(cur_sjinfo->syn_lefthand,
+								cur_sjinfo->syn_righthand)))
+		{
+			sjinfo = cur_sjinfo;
+			break;
+		}
+	}
+	if (sjinfo == NULL)
+		elog(ERROR, "failed to find SpecialJoinInfo for FULL JOIN clause");
+
+	/*
+	 * We can't deduce anything from the clause unless it relates an
+	 * expression of one input of the join to an expression of the other.
+	 * The operands may appear in either order, though, since the clause
+	 * might have been written as, say, "a FULL JOIN b ON b.y = a.x".  That
+	 * doesn't matter below, as the derived clauses are placed according to
+	 * their own relids.
+	 */
+	if (!(bms_is_subset(left_relids, sjinfo->syn_lefthand) &&
+		  bms_is_subset(right_relids, sjinfo->syn_righthand)) &&
+		!(bms_is_subset(left_relids, sjinfo->syn_righthand) &&
+		  bms_is_subset(right_relids, sjinfo->syn_lefthand)))
+		return false;
+
 	foreach(lc1, root->eq_classes)
 	{
 		EquivalenceClass *cur_ec = (EquivalenceClass *) lfirst(lc1);
diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index 3fa3ef6bd41..6d6fe2b3988 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -1905,9 +1905,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.)
+	 * doesn't have the info needed to do that, so we leave it to
+	 * reconsider_full_join_clause(), which looks up the join's
+	 * SpecialJoinInfo to work out which side each operand comes from.)
 	 *
 	 * 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 f816d952c90..7c68e4bd2b6 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -4046,6 +4046,75 @@ select a.unique1, b.unique1, c.unique1, coalesce(b.twothousand, a.twothousand)
 ---------+---------+---------+----------
 (0 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 reversed order: we can still deduce a.f1 = 0 and c.f1 = 0
+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             
+------------------------------------
+ Hash Full Join
+   Hash Cond: (a.f1 = c.f1)
+   ->  Seq Scan on int4_tbl a
+         Filter: (f1 = 0)
+   ->  Hash
+         ->  Seq Scan on int4_tbl c
+               Filter: (f1 = 0)
+(7 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)
+
+-- nothing can be deduced 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 0fe9131cdec..affe1c4cbaf 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -1354,6 +1354,39 @@ select a.unique1, b.unique1, c.unique1, coalesce(b.twothousand, a.twothousand)
   from tenk1 a left join tenk1 b on b.thousand = a.unique1                        left join tenk1 c on c.unique2 = coalesce(b.twothousand, a.twothousand)
   where a.unique2 < 10 and coalesce(b.twothousand, a.twothousand) = 44;
 
+--
+-- 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 reversed order: we can still deduce a.f1 = 0 and c.f1 = 0
+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;
+
+-- nothing can be deduced 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

