From 28c6fe5b81998b2cedc4aff38b7f779066253f41 Mon Sep 17 00:00:00 2001
From: Tomas Vondra <tomas@vondra.me>
Date: Sat, 31 Oct 2026 19:18:37 -0400
Subject: [PATCH v14] 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), as produced by a WHERE condition on a
merged column of FULL JOIN USING. If it finds both, it derives clauses
"LEFTVAR = CONSTANT" and "RIGHTVAR = CONSTANT", which can be pushed down
into the join's inputs, and removes the COALESCE member from the EC, so
the WHERE condition is not checked anymore.

That's valid only if LEFTVAR and RIGHTVAR come from different inputs of
the join, but the code never checked that. It holds for the clauses
generated for FULL JOIN USING, but not necessarily for a user-written ON
clause, and the COALESCE matching doesn't ensure it either. Nothing can
be deduced from a clause that doesn't relate an expression of one input
to an expression of the other, yet the code derived bogus equalities
from such clauses anyway, and dropped the WHERE condition.

A COALESCE() above a FULL JOIN can't become part of an EC unless it
references all rels of both join inputs (see check_equivalence_delay()),
so only a clause with an operand referencing both inputs can hit this.
Affected queries may produce incorrect results.

Fix by working out which input each operand comes from, and giving up
unless the operands come from different inputs. We have to look up the
SpecialJoinInfo in join_info_list. The outer_relids of a FULL JOIN
clause are all the rels syntactically within the join, which identifies
it uniquely.The operands may still appear in either order, which is
harmless, as the derived clauses are placed according to their relids.

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

Add regression tests with an operand referencing both inputs, and with
operands in reversed order, where the deduction is still valid.
---
 src/backend/optimizer/path/equivclass.c | 37 +++++++++++++++
 src/backend/optimizer/plan/initsplan.c  |  6 +--
 src/test/regress/expected/join.out      | 63 +++++++++++++++++++++++++
 src/test/regress/sql/join.sql           | 27 +++++++++++
 4 files changed, 130 insertions(+), 3 deletions(-)

diff --git a/src/backend/optimizer/path/equivclass.c b/src/backend/optimizer/path/equivclass.c
index 2a028d24152..f79b0fa4e86 100644
--- a/src/backend/optimizer/path/equivclass.c
+++ b/src/backend/optimizer/path/equivclass.c
@@ -2229,6 +2229,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 */
@@ -2249,6 +2250,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 eb498cbe2e8..eb57de51864 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 3d8042ee277..72000b79471 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -4046,6 +4046,69 @@ 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)
+
 --
 -- 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 9d3bbb94efc..861dcb610d5 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -1354,6 +1354,33 @@ 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;
+
 --
 -- check handling of join aliases when flattening multiple levels of subquery
 --
-- 
2.55.0

