From 11512f86a4411dc3118a84a4c8654f4cc3c74a9e Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Thu, 4 Jun 2026 17:38:53 +0900
Subject: [PATCH v2 2/4] Reduce FULL JOIN to ANTI JOIN

reduce_outer_joins() already recognizes that a LEFT JOIN is really an
anti-join when an upper qual forces a nullable-side Var to be NULL but
that Var is provably non-null in every row the nullable side emits.
Only null-extended rows can then satisfy the qual, so the matched rows
all drop out and JOIN_LEFT becomes JOIN_ANTI.

The same reasoning applies to a FULL JOIN.  If a forced-null Var on
one side is proven non-null, every row where that side is present,
matched or not, has the Var non-null and is dropped by the qual.  Only
the rows where that side was null-extended survive, which is an
anti-join that keeps the other side's unmatched rows.

When the proven Var is on the RHS, the surviving LHS rows are already
the left input, so this is a plain JOIN_ANTI.  When it is on the LHS,
the surviving RHS rows must become the left input, so we tag the join
JOIN_RIGHT_ANTI and let the existing input-switching step, the one
that flips JOIN_RIGHT to JOIN_LEFT, normalize it to JOIN_ANTI.  Unlike
the LEFT JOIN case, the join's own ON quals cannot serve as proof
here, because they do not hold for the unmatched rows the proof must
cover.

The proof that a forced-null Var is non-null, from the quals that hold
for every row a subtree emits (optionally plus extra quals the caller
supplies) or from a NOT NULL constraint, is factored into
forced_null_var_is_nonnullable() and shared by the LEFT and FULL
paths.
---
 src/backend/optimizer/prep/prepjointree.c | 159 +++++++++++++++------
 src/test/regress/expected/join.out        | 164 ++++++++++++++++++++++
 src/test/regress/sql/join.sql             |  55 ++++++++
 3 files changed, 335 insertions(+), 43 deletions(-)

diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index 207c50cda9a..48631fb99c6 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -167,8 +167,13 @@ static void reduce_outer_joins_pass2(Node *jtnode,
 									 List *forced_null_vars);
 static void report_reduced_full_join(reduce_outer_joins_pass2_state *state2,
 									 int rtindex, Relids relids);
-static bool has_notnull_forced_var(PlannerInfo *root, List *forced_null_vars,
-								   reduce_outer_joins_pass1_state *right_state);
+static bool forced_null_var_is_attnotnull(PlannerInfo *root,
+										  List *forced_null_vars,
+										  reduce_outer_joins_pass1_state *state);
+static bool forced_null_var_is_nonnullable(PlannerInfo *root,
+										   List *forced_null_vars,
+										   reduce_outer_joins_pass1_state *state,
+										   List *extra_quals);
 static Node *remove_useless_results_recurse(PlannerInfo *root, Node *jtnode,
 											Node **parent_quals,
 											Relids *dropped_outer_joins);
@@ -3247,6 +3252,12 @@ flatten_simple_union_all(PlannerInfo *root)
  * removed to prevent bogus selectivity calculations, but we leave it to
  * distribute_qual_to_rels to get rid of such clauses.
  *
+ * The same recognition reduces a FULL join to an anti-semijoin when a
+ * forced-null Var on either side is proven non-null: only the other side's
+ * unmatched rows can survive.  If that surviving side is the right-hand
+ * input, we switch the inputs (as we do for JOIN_RIGHT below) so that it
+ * ends up on the left, where JOIN_ANTI requires the surviving side to be.
+ *
  * Also, we get rid of JOIN_RIGHT cases by flipping them around to become
  * JOIN_LEFT.  This saves some code here and in some later planner routines;
  * the main benefit is to reduce the number of jointypes that can appear in
@@ -3545,12 +3556,48 @@ reduce_outer_joins_pass2(Node *jtnode,
 												 right_state->relids);
 					}
 				}
-				else
+				else if (bms_overlap(nonnullable_rels, right_state->relids))
 				{
-					if (bms_overlap(nonnullable_rels, right_state->relids))
+					jointype = JOIN_RIGHT;
+					/* Also report partial reduction in state2 */
+					report_reduced_full_join(state2, rtindex,
+											 left_state->relids);
+				}
+				else if (forced_null_vars != NIL)
+				{
+					/*
+					 * Neither side is forced non-null by a strict upper qual,
+					 * but an upper qual may force a Var on one side to be
+					 * NULL while that Var is non-null in every row that side
+					 * emits (proven by quals within the side's own subtree,
+					 * or a NOT NULL constraint).  Then only rows where that
+					 * side was null-extended can satisfy the upper qual: the
+					 * matched rows and that side's unmatched rows all drop
+					 * out, leaving an anti-join.
+					 *
+					 * Unlike the JOIN_LEFT case below, we must not consult
+					 * the join's own ON quals here: they do not hold for the
+					 * unmatched rows that this proof has to cover.
+					 *
+					 * If the constrained Var is on the RHS the result is a
+					 * plain anti-join; if it is on the LHS it is a right
+					 * anti-join, which the input-switching step below
+					 * normalizes to a plain anti-join (just as it does for
+					 * JOIN_RIGHT).
+					 */
+					if (forced_null_var_is_nonnullable(root,
+													   forced_null_vars,
+													   right_state, NIL))
 					{
-						jointype = JOIN_RIGHT;
-						/* Also report partial reduction in state2 */
+						jointype = JOIN_ANTI;
+						report_reduced_full_join(state2, rtindex,
+												 right_state->relids);
+					}
+					else if (forced_null_var_is_nonnullable(root,
+															forced_null_vars,
+															left_state, NIL))
+					{
+						jointype = JOIN_RIGHT_ANTI;
 						report_reduced_full_join(state2, rtindex,
 												 left_state->relids);
 					}
@@ -3563,7 +3610,9 @@ reduce_outer_joins_pass2(Node *jtnode,
 				 * These could only have been introduced by pull_up_sublinks,
 				 * so there's no way that upper quals could refer to their
 				 * righthand sides, and no point in checking.  We don't expect
-				 * to see JOIN_RIGHT_SEMI or JOIN_RIGHT_ANTI yet.
+				 * a JOIN_RIGHT_SEMI or JOIN_RIGHT_ANTI input here; the
+				 * JOIN_FULL case above produces JOIN_RIGHT_ANTI only as a
+				 * transient, which is converted to JOIN_ANTI below.
 				 */
 				break;
 			default:
@@ -3573,20 +3622,22 @@ reduce_outer_joins_pass2(Node *jtnode,
 		}
 
 		/*
-		 * Convert JOIN_RIGHT to JOIN_LEFT.  Note that in the case where we
-		 * reduced JOIN_FULL to JOIN_RIGHT, this will mean the JoinExpr no
-		 * longer matches the internal ordering of any CoalesceExpr's built to
-		 * represent merged join variables.  We don't care about that at
-		 * present, but be wary of it ...
+		 * Convert JOIN_RIGHT to JOIN_LEFT, and likewise the JOIN_RIGHT_ANTI
+		 * that the JOIN_FULL arm may have produced just above to JOIN_ANTI,
+		 * by switching the inputs.  Note that in the case where we reduced
+		 * JOIN_FULL this way, this will mean the JoinExpr no longer matches
+		 * the internal ordering of any CoalesceExpr's built to represent
+		 * merged join variables.  We don't care about that at present, but be
+		 * wary of it ...
 		 */
-		if (jointype == JOIN_RIGHT)
+		if (jointype == JOIN_RIGHT || jointype == JOIN_RIGHT_ANTI)
 		{
 			Node	   *tmparg;
 
 			tmparg = j->larg;
 			j->larg = j->rarg;
 			j->rarg = tmparg;
-			jointype = JOIN_LEFT;
+			jointype = (jointype == JOIN_RIGHT) ? JOIN_LEFT : JOIN_ANTI;
 			right_state = linitial(state1->sub_states);
 			left_state = lsecond(state1->sub_states);
 		}
@@ -3605,30 +3656,12 @@ reduce_outer_joins_pass2(Node *jtnode,
 		 */
 		if (jointype == JOIN_LEFT && forced_null_vars != NIL)
 		{
-			List	   *nonnullable_vars;
-			List	   *all_quals;
-			Bitmapset  *overlap;
-
 			/*
-			 * Find Vars that must be non-null in matching rows: those made
-			 * non-null by this join's own quals, plus those the RHS subtree
-			 * guarantees non-null in all of its output rows.
-			 */
-			all_quals = list_concat_copy(right_state->safe_quals,
-										 (List *) j->quals);
-			nonnullable_vars = find_nonnullable_vars((Node *) all_quals);
-
-			/*
-			 * It's not sufficient to check whether nonnullable_vars and
-			 * forced_null_vars overlap: we need to know if the overlap
-			 * includes any RHS variables.
-			 *
-			 * Also check if any forced-null var is defined NOT NULL by table
-			 * constraints.
+			 * A forced-null RHS Var that is proven non-null can be NULL here
+			 * only by null-extension.  That makes this an anti-join.
 			 */
-			overlap = mbms_overlap_sets(nonnullable_vars, forced_null_vars);
-			if (bms_overlap(overlap, right_state->relids) ||
-				has_notnull_forced_var(root, forced_null_vars, right_state))
+			if (forced_null_var_is_nonnullable(root, forced_null_vars,
+											   right_state, (List *) j->quals))
 				jointype = JOIN_ANTI;
 		}
 
@@ -3765,10 +3798,10 @@ report_reduced_full_join(reduce_outer_joins_pass2_state *state2,
 }
 
 /*
- * has_notnull_forced_var
+ * forced_null_var_is_attnotnull
  *		Check if "forced_null_vars" contains any Vars belonging to the subtree
- *		indicated by "right_state" that are known to be non-nullable due to
- *		table constraints.
+ *		indicated by "state" that are known to be non-nullable due to table
+ *		constraints.
  *
  * Note that we must also consider the situation where a NOT NULL Var can be
  * nulled by lower-level outer joins.
@@ -3776,8 +3809,8 @@ report_reduced_full_join(reduce_outer_joins_pass2_state *state2,
  * Helper for reduce_outer_joins_pass2.
  */
 static bool
-has_notnull_forced_var(PlannerInfo *root, List *forced_null_vars,
-					   reduce_outer_joins_pass1_state *right_state)
+forced_null_var_is_attnotnull(PlannerInfo *root, List *forced_null_vars,
+							  reduce_outer_joins_pass1_state *state)
 {
 	int			varno = -1;
 
@@ -3795,7 +3828,7 @@ has_notnull_forced_var(PlannerInfo *root, List *forced_null_vars,
 			continue;
 
 		/* Skip Vars that do not belong to the target relations */
-		if (!bms_is_member(varno, right_state->relids))
+		if (!bms_is_member(varno, state->relids))
 			continue;
 
 		/*
@@ -3803,7 +3836,7 @@ has_notnull_forced_var(PlannerInfo *root, List *forced_null_vars,
 		 * given subtree.  These Vars might be NULL even if the schema defines
 		 * them as NOT NULL.
 		 */
-		if (bms_is_member(varno, right_state->nullable_rels))
+		if (bms_is_member(varno, state->nullable_rels))
 			continue;
 
 		/*
@@ -3862,6 +3895,46 @@ has_notnull_forced_var(PlannerInfo *root, List *forced_null_vars,
 	return false;
 }
 
+/*
+ * forced_null_var_is_nonnullable
+ *		Detect whether some Var that "forced_null_vars" requires to be NULL is
+ *		actually non-nullable in every row that the given subtree emits.
+ *
+ * We prove non-nullness from quals that hold for every such row: the subtree's
+ * collected safe_quals, plus any "extra_quals" the caller knows also constrain
+ * the Var, or a NOT NULL table constraint (excluding Vars nullable due to
+ * lower-level outer joins).
+ *
+ * Helper for reduce_outer_joins_pass2.
+ */
+static bool
+forced_null_var_is_nonnullable(PlannerInfo *root, List *forced_null_vars,
+							   reduce_outer_joins_pass1_state *state,
+							   List *extra_quals)
+{
+	List	   *all_quals;
+	List	   *nonnullable_vars;
+	Bitmapset  *overlap;
+
+	all_quals = list_concat_copy(state->safe_quals, extra_quals);
+	nonnullable_vars = find_nonnullable_vars((Node *) all_quals);
+
+	/*
+	 * It's not sufficient to check whether nonnullable_vars and
+	 * forced_null_vars overlap: we need to know if the overlap includes any
+	 * variables of this subtree.
+	 */
+	overlap = mbms_overlap_sets(nonnullable_vars, forced_null_vars);
+	if (bms_overlap(overlap, state->relids))
+		return true;
+
+	/*
+	 * Otherwise, check if any forced-null var is defined NOT NULL by table
+	 * constraints.
+	 */
+	return forced_null_var_is_attnotnull(root, forced_null_vars, state);
+}
+
 
 /*
  * remove_useless_result_rtes
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 75d945bcfa4..b318cddd7c0 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -3545,6 +3545,170 @@ where t1.b is null;
                Filter: (b IS NULL)
 (13 rows)
 
+-- a full join can also reduce to an antijoin: the forced-null Var is defined
+-- not null
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t2.a is null;
+             QUERY PLAN              
+-------------------------------------
+ Hash Anti Join
+   Hash Cond: (t1.b = t2.b)
+   ->  Seq Scan on tbl_anti t1
+   ->  Hash
+         ->  Seq Scan on tbl_anti t2
+(5 rows)
+
+-- the symmetric case, with the forced-null Var on the left side
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t1.a is null;
+             QUERY PLAN              
+-------------------------------------
+ Hash Anti Join
+   Hash Cond: (t2.b = t1.b)
+   ->  Seq Scan on tbl_anti t2
+   ->  Hash
+         ->  Seq Scan on tbl_anti t1
+(5 rows)
+
+-- this is an antijoin: the forced-null Var is proven not null by the strict
+-- clause within its subtree
+explain (costs off)
+select * from tbl_anti t1 full join
+  (tbl_anti t2 join tbl_anti t3 on t2.c = t3.c) on t1.b = t2.b
+where t3.c is null;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Hash Anti Join
+   Hash Cond: (t1.b = t2.b)
+   ->  Seq Scan on tbl_anti t1
+   ->  Hash
+         ->  Merge Join
+               Merge Cond: (t2.c = t3.c)
+               ->  Sort
+                     Sort Key: t2.c
+                     ->  Seq Scan on tbl_anti t2
+               ->  Sort
+                     Sort Key: t3.c
+                     ->  Seq Scan on tbl_anti t3
+(12 rows)
+
+-- the symmetric case, with the forced-null Var on the left side
+explain (costs off)
+select * from (tbl_anti t1 join tbl_anti t4 on t1.c = t4.c)
+  full join tbl_anti t2 on t1.b = t2.b
+where t1.c is null;
+                   QUERY PLAN                    
+-------------------------------------------------
+ Hash Anti Join
+   Hash Cond: (t2.b = t1.b)
+   ->  Seq Scan on tbl_anti t2
+   ->  Hash
+         ->  Merge Join
+               Merge Cond: (t1.c = t4.c)
+               ->  Sort
+                     Sort Key: t1.c
+                     ->  Seq Scan on tbl_anti t1
+               ->  Sort
+                     Sort Key: t4.c
+                     ->  Seq Scan on tbl_anti t4
+(12 rows)
+
+-- this is not an antijoin: the join clause cannot prove t2.b non-null in the
+-- full join's right-only rows
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t2.b is null;
+             QUERY PLAN              
+-------------------------------------
+ Merge Full Join
+   Merge Cond: (t1.b = t2.b)
+   Filter: (t2.b IS NULL)
+   ->  Sort
+         Sort Key: t1.b
+         ->  Seq Scan on tbl_anti t1
+   ->  Sort
+         Sort Key: t2.b
+         ->  Seq Scan on tbl_anti t2
+(9 rows)
+
+-- the symmetric case: the join clause cannot prove t1.b non-null in the full
+-- join's left-only rows
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t1.b is null;
+             QUERY PLAN              
+-------------------------------------
+ Merge Full Join
+   Merge Cond: (t1.b = t2.b)
+   Filter: (t1.b IS NULL)
+   ->  Sort
+         Sort Key: t1.b
+         ->  Seq Scan on tbl_anti t1
+   ->  Sort
+         Sort Key: t2.b
+         ->  Seq Scan on tbl_anti t2
+(9 rows)
+
+-- A USING join column becomes a merged (COALESCE) output, so reducing the
+-- full join also has to fix up its nulling rels.
+create temp table ma (x int not null, y int);
+create temp table mb (x int not null, y int);
+insert into ma values (1, 10), (2, 20);
+insert into mb values (3, 10), (4, 40);
+explain (verbose, costs off)
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where mb.x is null order by y;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Sort
+   Output: (COALESCE(ma.y, mb.y)), ma.x, mb.x
+   Sort Key: (COALESCE(ma.y, mb.y))
+   ->  Hash Anti Join
+         Output: COALESCE(ma.y, mb.y), ma.x, mb.x
+         Hash Cond: (ma.y = mb.y)
+         ->  Seq Scan on pg_temp.ma
+               Output: ma.x, ma.y
+         ->  Hash
+               Output: mb.y, mb.x
+               ->  Seq Scan on pg_temp.mb
+                     Output: mb.y, mb.x
+(12 rows)
+
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where mb.x is null order by y;
+ y  | ax | bx 
+----+----+----
+ 20 |  2 |   
+(1 row)
+
+explain (verbose, costs off)
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where ma.x is null order by y;
+                    QUERY PLAN                    
+--------------------------------------------------
+ Sort
+   Output: (COALESCE(ma.y, mb.y)), ma.x, mb.x
+   Sort Key: (COALESCE(ma.y, mb.y))
+   ->  Hash Anti Join
+         Output: COALESCE(ma.y, mb.y), ma.x, mb.x
+         Hash Cond: (mb.y = ma.y)
+         ->  Seq Scan on pg_temp.mb
+               Output: mb.x, mb.y
+         ->  Hash
+               Output: ma.y, ma.x
+               ->  Seq Scan on pg_temp.ma
+                     Output: ma.y, ma.x
+(12 rows)
+
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where ma.x is null order by y;
+ y  | ax | bx 
+----+----+----
+ 40 |    |  4
+(1 row)
+
 rollback;
 --
 -- regression test for bogus RTE_GROUP entries
diff --git a/src/test/regress/sql/join.sql b/src/test/regress/sql/join.sql
index 185635c29f5..00933c81197 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -969,6 +969,61 @@ select * from tbl_anti t1
                      where t2.c = t3.c and t3.b = t1.b) ss on true
 where t1.b is null;
 
+-- a full join can also reduce to an antijoin: the forced-null Var is defined
+-- not null
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t2.a is null;
+
+-- the symmetric case, with the forced-null Var on the left side
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t1.a is null;
+
+-- this is an antijoin: the forced-null Var is proven not null by the strict
+-- clause within its subtree
+explain (costs off)
+select * from tbl_anti t1 full join
+  (tbl_anti t2 join tbl_anti t3 on t2.c = t3.c) on t1.b = t2.b
+where t3.c is null;
+
+-- the symmetric case, with the forced-null Var on the left side
+explain (costs off)
+select * from (tbl_anti t1 join tbl_anti t4 on t1.c = t4.c)
+  full join tbl_anti t2 on t1.b = t2.b
+where t1.c is null;
+
+-- this is not an antijoin: the join clause cannot prove t2.b non-null in the
+-- full join's right-only rows
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t2.b is null;
+
+-- the symmetric case: the join clause cannot prove t1.b non-null in the full
+-- join's left-only rows
+explain (costs off)
+select * from tbl_anti t1 full join tbl_anti t2 on t1.b = t2.b
+where t1.b is null;
+
+-- A USING join column becomes a merged (COALESCE) output, so reducing the
+-- full join also has to fix up its nulling rels.
+create temp table ma (x int not null, y int);
+create temp table mb (x int not null, y int);
+insert into ma values (1, 10), (2, 20);
+insert into mb values (3, 10), (4, 40);
+
+explain (verbose, costs off)
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where mb.x is null order by y;
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where mb.x is null order by y;
+
+explain (verbose, costs off)
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where ma.x is null order by y;
+select y, ma.x as ax, mb.x as bx
+from ma full join mb using (y) where ma.x is null order by y;
+
 rollback;
 
 --
-- 
2.39.5 (Apple Git-154)

