From dcf0df2223fe8e1a8a0c6d9d34b37a9ed34e927e Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Thu, 4 Jun 2026 11:13:35 +0900
Subject: [PATCH v2 1/4] Reduce LEFT JOIN to ANTI JOIN using quals within the
 RHS subtree

reduce_outer_joins() turns a LEFT JOIN into an ANTI JOIN when some Var
that an upper qual requires to be NULL is actually non-nullable in any
matching row.  When that holds, only null-extended (unmatched) rows
can satisfy the upper qual, which is exactly anti-join semantics.
Until now we recognized such a Var as non-nullable only when the
join's own clauses were strict for it, or when it was defined NOT NULL
by table constraints.

This patch allows strict quals applied within the RHS subtree to serve
as the proof as well.  Because such quals hold for every row the RHS
emits, they hold for every matching row, so a Var they force non-null
can become NULL above the join only by null-extension.

To avoid re-walking the jointree at decision time, the first pass of
the reduce-outer-joins process gathers these proving quals into its
per-subtree state, alongside nullable_rels.  The second pass then
proves non-nullness from the RHS subtree's collected quals together
with the join's own ON quals.  As before, the reduction fires only
when the proven, forced-null Var belongs to the RHS of the join.
---
 src/backend/optimizer/prep/prepjointree.c |  74 ++++++---
 src/test/regress/expected/join.out        | 189 +++++++++++++++++++++-
 src/test/regress/sql/join.sql             |  61 ++++++-
 3 files changed, 303 insertions(+), 21 deletions(-)

diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index 4424fdbe906..207c50cda9a 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -90,6 +90,10 @@ typedef struct reduce_outer_joins_pass1_state
 	bool		contains_outer; /* does subtree contain outer join(s)? */
 	Relids		nullable_rels;	/* base relids that are nullable within this
 								 * subtree */
+	List	   *safe_quals;		/* quals (implicit-AND) that are applied to
+								 * every output row of this subtree, and so
+								 * can be used to prove non-nullability of its
+								 * outputs */
 	List	   *sub_states;		/* List of states for subtree components */
 } reduce_outer_joins_pass1_state;
 
@@ -3232,15 +3236,16 @@ flatten_simple_union_all(PlannerInfo *root)
  *
  * Another transformation we apply here is to recognize cases like
  *		SELECT ... FROM a LEFT JOIN b ON (a.x = b.y) WHERE b.z IS NULL;
- * If we can prove that b.z must be non-null for any matching row, either
- * because the join clause is strict for b.z and b.z happens to be the join
- * key b.y, or because b.z is defined NOT NULL by table constraints and is
- * not nullable due to lower-level outer joins, then only null-extended rows
- * could pass the upper WHERE, and we can conclude that what the query is
- * really specifying is an anti-semijoin.  We change the join type from
- * JOIN_LEFT to JOIN_ANTI.  The IS NULL clause then becomes redundant, and
- * must be removed to prevent bogus selectivity calculations, but we leave
- * it to distribute_qual_to_rels to get rid of such clauses.
+ * If we can prove that b.z must be non-null for any matching row, because
+ * the join clause is strict for b.z and b.z happens to be the join key b.y,
+ * because a strict qual within b's own subtree forces b.z non-null, or
+ * because b.z is defined NOT NULL by table constraints and is not nullable
+ * due to lower-level outer joins, then only null-extended rows could pass
+ * the upper WHERE, and we can conclude that what the query is really
+ * specifying is an anti-semijoin.  We change the join type from JOIN_LEFT
+ * to JOIN_ANTI.  The IS NULL clause then becomes redundant, and must be
+ * removed to prevent bogus selectivity calculations, but we leave it to
+ * distribute_qual_to_rels to get rid of such clauses.
  *
  * 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;
@@ -3335,6 +3340,7 @@ reduce_outer_joins_pass1(Node *jtnode)
 	result->relids = NULL;
 	result->contains_outer = false;
 	result->nullable_rels = NULL;
+	result->safe_quals = NIL;
 	result->sub_states = NIL;
 
 	if (jtnode == NULL)
@@ -3360,8 +3366,15 @@ reduce_outer_joins_pass1(Node *jtnode)
 			result->contains_outer |= sub_state->contains_outer;
 			result->nullable_rels = bms_add_members(result->nullable_rels,
 													sub_state->nullable_rels);
+			/* All of a FROM item's safe quals are safe at this level too */
+			result->safe_quals = list_concat(result->safe_quals,
+											 sub_state->safe_quals);
 			result->sub_states = lappend(result->sub_states, sub_state);
 		}
+		/* ... and so are this FromExpr's own WHERE quals */
+		if (f->quals)
+			result->safe_quals = list_concat(result->safe_quals,
+											 (List *) f->quals);
 	}
 	else if (IsA(jtnode, JoinExpr))
 	{
@@ -3379,7 +3392,13 @@ reduce_outer_joins_pass1(Node *jtnode)
 		/* Store children's states for pass 2 */
 		result->sub_states = list_make2(left_state, right_state);
 
-		/* Collect outer join information */
+		/*
+		 * Collect outer join information.  For safe_quals we gather the quals
+		 * that hold for every output row of the subtree: at an inner or semi
+		 * join that's both children's safe quals plus our own ON quals, while
+		 * at an outer join we must exclude its ON quals and its nullable
+		 * side(s).
+		 */
 		switch (j->jointype)
 		{
 			case JOIN_INNER:
@@ -3389,6 +3408,12 @@ reduce_outer_joins_pass1(Node *jtnode)
 					right_state->contains_outer;
 				result->nullable_rels = bms_union(left_state->nullable_rels,
 												  right_state->nullable_rels);
+				/* Both children's quals, plus our own ON quals, are safe */
+				result->safe_quals = list_concat_copy(left_state->safe_quals,
+													  right_state->safe_quals);
+				if (j->quals)
+					result->safe_quals = list_concat(result->safe_quals,
+													 (List *) j->quals);
 				break;
 			case JOIN_LEFT:
 			case JOIN_ANTI:
@@ -3396,18 +3421,21 @@ reduce_outer_joins_pass1(Node *jtnode)
 				result->contains_outer = true;
 				result->nullable_rels = bms_union(left_state->nullable_rels,
 												  right_state->relids);
+				result->safe_quals = left_state->safe_quals;
 				break;
 			case JOIN_RIGHT:
 				/* LHS is nullable; RHS keeps existing status */
 				result->contains_outer = true;
 				result->nullable_rels = bms_union(left_state->relids,
 												  right_state->nullable_rels);
+				result->safe_quals = right_state->safe_quals;
 				break;
 			case JOIN_FULL:
 				/* Both sides are nullable */
 				result->contains_outer = true;
 				result->nullable_rels = bms_union(left_state->relids,
 												  right_state->relids);
+				/* safe_quals stays NIL */
 				break;
 			default:
 				elog(ERROR, "unrecognized join type: %d",
@@ -3566,21 +3594,29 @@ reduce_outer_joins_pass2(Node *jtnode,
 		/*
 		 * See if we can reduce JOIN_LEFT to JOIN_ANTI.  This is the case if
 		 * any var from the RHS was forced null by higher qual levels, but is
-		 * known to be non-nullable.  We detect this either by seeing if the
-		 * join's own quals are strict for the var, or by checking if the var
-		 * is defined NOT NULL by table constraints (being careful to exclude
-		 * vars that are nullable due to lower-level outer joins).  In either
-		 * case, the only way the higher qual clause's requirement for NULL
-		 * can be met is if the join fails to match, producing a null-extended
-		 * row.  Thus, we can treat this as an anti-join.
+		 * known to be non-nullable in any matching row.  We can prove that in
+		 * any of these ways: the join's own quals are strict for the var;
+		 * strict quals applied within the RHS subtree prove it; or the var is
+		 * defined NOT NULL by table constraints (being careful to exclude
+		 * vars that are nullable due to lower-level outer joins).  In each
+		 * such case, the only way the higher qual clause's requirement for
+		 * NULL can be met is if the join fails to match, producing a
+		 * null-extended row.  Thus, we can treat this as an anti-join.
 		 */
 		if (jointype == JOIN_LEFT && forced_null_vars != NIL)
 		{
 			List	   *nonnullable_vars;
+			List	   *all_quals;
 			Bitmapset  *overlap;
 
-			/* Find Vars in j->quals that must be non-null in joined rows */
-			nonnullable_vars = find_nonnullable_vars(j->quals);
+			/*
+			 * 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
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 78bf022f7b4..75d945bcfa4 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -3298,7 +3298,8 @@ where (hundred, thousand) in (select twothousand, twothousand from onek);
 
 reset enable_memoize;
 --
--- more antijoin recognition tests using NOT NULL constraints
+-- more antijoin recognition tests using various non-null proofs (NOT NULL
+-- constraints, strict join clauses within the RHS subtree)
 --
 begin;
 create temp table tbl_anti(a int not null, b int, c int);
@@ -3358,6 +3359,192 @@ where t3.a is null;
          ->  Seq Scan on tenk1 t1
 (13 rows)
 
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t3.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 inner join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Right Anti Join
+   Hash Cond: (t2.b = t1.unique1)
+   ->  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
+   ->  Hash
+         ->  Seq Scan on tenk1 t1
+(12 rows)
+
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t2.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 inner join tbl_anti t3 on t2.c = t3.c
+   left join tbl_anti t4 on t3.c = t4.c) on t1.unique1 = t2.b
+where t2.c is null;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Hash Right Anti Join
+   Hash Cond: (t2.b = t1.unique1)
+   ->  Merge Right Join
+         Merge Cond: (t4.c = t3.c)
+         ->  Sort
+               Sort Key: t4.c
+               ->  Seq Scan on tbl_anti t4
+         ->  Materialize
+               ->  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
+   ->  Hash
+         ->  Seq Scan on tenk1 t1
+(18 rows)
+
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t3.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (select t2.b, t3.c from tbl_anti t2, tbl_anti t3
+   where t2.c = t3.c) ss on t1.unique1 = ss.b
+where ss.c is null;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Right Anti Join
+   Hash Cond: (t2.b = t1.unique1)
+   ->  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
+   ->  Hash
+         ->  Seq Scan on tenk1 t1
+(12 rows)
+
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t2.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (select t2.c from tbl_anti t2
+   where exists (select 1 from tbl_anti t3 where t2.c = t3.c)) ss on true
+where ss.c is null;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Nested Loop Anti Join
+   ->  Seq Scan on tenk1 t1
+   ->  Materialize
+         ->  Hash Join
+               Hash Cond: (t2.c = t3.c)
+               ->  Seq Scan on tbl_anti t2
+               ->  Hash
+                     ->  HashAggregate
+                           Group Key: t3.c
+                           ->  Seq Scan on tbl_anti t3
+(10 rows)
+
+-- this is not an antijoin: the join clause t2.c = t3.c cannot guarantee t3.c
+-- is non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Right Join
+   Hash Cond: (t2.b = t1.unique1)
+   Filter: (t3.c IS NULL)
+   ->  Merge Left 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
+   ->  Hash
+         ->  Seq Scan on tenk1 t1
+(13 rows)
+
+-- likewise with right join
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 right join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Right Join
+   Hash Cond: (t2.b = t1.unique1)
+   Filter: (t3.c IS NULL)
+   ->  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
+   ->  Hash
+         ->  Seq Scan on tenk1 t1
+(13 rows)
+
+-- likewise with full join
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 full join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Right Join
+   Hash Cond: (t2.b = t1.unique1)
+   Filter: (t3.c IS NULL)
+   ->  Merge Left 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
+   ->  Hash
+         ->  Seq Scan on tenk1 t1
+(13 rows)
+
+-- this is not an antijoin, even though the inner-join clause in the lateral
+-- subquery proves t1.b non-null
+explain (costs off)
+select * from tbl_anti t1
+  left join lateral (select t2.b from tbl_anti t2, tbl_anti t3
+                     where t2.c = t3.c and t3.b = t1.b) ss on true
+where t1.b is null;
+                QUERY PLAN                 
+-------------------------------------------
+ Hash Right Join
+   Hash Cond: (t3.b = t1.b)
+   ->  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
+   ->  Hash
+         ->  Seq Scan on tbl_anti t1
+               Filter: (b IS NULL)
+(13 rows)
+
 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 fae19113cef..185635c29f5 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -886,7 +886,8 @@ where (hundred, thousand) in (select twothousand, twothousand from onek);
 reset enable_memoize;
 
 --
--- more antijoin recognition tests using NOT NULL constraints
+-- more antijoin recognition tests using various non-null proofs (NOT NULL
+-- constraints, strict join clauses within the RHS subtree)
 --
 
 begin;
@@ -910,6 +911,64 @@ select * from tenk1 t1 left join
   (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
 where t3.a is null;
 
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t3.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 inner join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t2.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 inner join tbl_anti t3 on t2.c = t3.c
+   left join tbl_anti t4 on t3.c = t4.c) on t1.unique1 = t2.b
+where t2.c is null;
+
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t3.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (select t2.b, t3.c from tbl_anti t2, tbl_anti t3
+   where t2.c = t3.c) ss on t1.unique1 = ss.b
+where ss.c is null;
+
+-- this is an antijoin: the strict join clause t2.c = t3.c guarantees t2.c is
+-- non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (select t2.c from tbl_anti t2
+   where exists (select 1 from tbl_anti t3 where t2.c = t3.c)) ss on true
+where ss.c is null;
+
+-- this is not an antijoin: the join clause t2.c = t3.c cannot guarantee t3.c
+-- is non-null
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 left join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+
+-- likewise with right join
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 right join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+
+-- likewise with full join
+explain (costs off)
+select * from tenk1 t1 left join
+  (tbl_anti t2 full join tbl_anti t3 on t2.c = t3.c) on t1.unique1 = t2.b
+where t3.c is null;
+
+-- this is not an antijoin, even though the inner-join clause in the lateral
+-- subquery proves t1.b non-null
+explain (costs off)
+select * from tbl_anti t1
+  left join lateral (select t2.b from tbl_anti t2, tbl_anti t3
+                     where t2.c = t3.c and t3.b = t1.b) ss on true
+where t1.b is null;
+
 rollback;
 
 --
-- 
2.39.5 (Apple Git-154)

