From 0f1409bb76a742765c4dd6b7b359524ad52de085 Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Mon, 31 Aug 2026 15:15:23 +0900
Subject: [PATCH v1] Remove quals made redundant by reducing outer joins to
 antijoins

When reduce_outer_joins reduces an outer join to an antijoin, any IS
NULL qual on a Var from the antijoin's nullable side is necessarily
true.  Previously, such quals were discarded later in
distribute_qual_to_rels, mainly to avoid bogus selectivity estimates.
But that discard was one-sided: the qual remained in the jointree,
while its Vars were not counted in attr_needed.  Since commit
2ebf25e7d, join removal edits the jointree and expects it to contain
no other references to a removed rel, so it could remove a rel that
such a discarded qual still references, and then trip an assertion on
the qual's stale Var.

To fix, remove these quals from the jointree at the end of
reduce_outer_joins.  With the quals gone before deconstruct_jointree,
check_redundant_nullability_qual has nothing left to detect, so remove
it.
---
 src/backend/optimizer/plan/initsplan.c    |  61 ------------
 src/backend/optimizer/prep/prepjointree.c | 107 ++++++++++++++++++++--
 src/test/regress/expected/join.out        |  35 +++++++
 src/test/regress/sql/join.sql             |  18 ++++
 4 files changed, 153 insertions(+), 68 deletions(-)

diff --git a/src/backend/optimizer/plan/initsplan.c b/src/backend/optimizer/plan/initsplan.c
index a2fb0b55a79..fb6f81453ea 100644
--- a/src/backend/optimizer/plan/initsplan.c
+++ b/src/backend/optimizer/plan/initsplan.c
@@ -146,7 +146,6 @@ static void distribute_qual_to_rels(PlannerInfo *root, Node *clause,
 									bool has_clone,
 									bool is_clone,
 									List **postponed_oj_qual_list);
-static bool check_redundant_nullability_qual(PlannerInfo *root, Node *clause);
 static Relids get_join_domain_min_rels(PlannerInfo *root, Relids domain_relids);
 static void check_mergejoinable(RestrictInfo *restrictinfo);
 static void check_hashjoinable(RestrictInfo *restrictinfo);
@@ -2871,10 +2870,6 @@ distribute_quals_to_rels(PlannerInfo *root, List *clauses,
  * 'qualscope' identifies what level of JOIN the qual came from syntactically.
  * 'ojscope' is needed if we decide to force the qual up to the outer-join
  * level, which will be ojscope not necessarily qualscope.
- *
- * At the time this is called, root->join_info_list must contain entries for
- * at least those special joins that are syntactically below this qual.
- * (We now need that only for detection of redundant IS NULL quals.)
  */
 static void
 distribute_qual_to_rels(PlannerInfo *root, Node *clause,
@@ -3078,15 +3073,6 @@ distribute_qual_to_rels(PlannerInfo *root, Node *clause,
 		 */
 		is_pushed_down = true;
 
-		/*
-		 * It's possible that this is an IS NULL clause that's redundant with
-		 * a lower antijoin; if so we can just discard it.  We need not test
-		 * in any of the other cases, because this will only be possible for
-		 * pushed-down clauses.
-		 */
-		if (check_redundant_nullability_qual(root, clause))
-			return;
-
 		/* Feed qual to the equivalence machinery, if allowed by caller */
 		maybe_equivalence = allow_equivalence;
 
@@ -3253,53 +3239,6 @@ distribute_qual_to_rels(PlannerInfo *root, Node *clause,
 	distribute_restrictinfo_to_rels(root, restrictinfo);
 }
 
-/*
- * check_redundant_nullability_qual
- *	  Check to see if the qual is an IS NULL qual that is redundant with
- *	  a lower JOIN_ANTI join.
- *
- * We want to suppress redundant IS NULL quals, not so much to save cycles
- * as to avoid generating bogus selectivity estimates for them.  So if
- * redundancy is detected here, distribute_qual_to_rels() just throws away
- * the qual.
- */
-static bool
-check_redundant_nullability_qual(PlannerInfo *root, Node *clause)
-{
-	Var		   *forced_null_var;
-	ListCell   *lc;
-
-	/* Check for IS NULL, and identify the Var forced to NULL */
-	forced_null_var = find_forced_null_var(clause);
-	if (forced_null_var == NULL)
-		return false;
-
-	/*
-	 * If the Var comes from the nullable side of a lower antijoin, the IS
-	 * NULL condition is necessarily true.  If it's not nulled by anything,
-	 * there is no point in searching the join_info_list.  Otherwise, we need
-	 * to find out whether the nulling rel is an antijoin.
-	 */
-	if (forced_null_var->varnullingrels == NULL)
-		return false;
-
-	foreach(lc, root->join_info_list)
-	{
-		SpecialJoinInfo *sjinfo = (SpecialJoinInfo *) lfirst(lc);
-
-		/*
-		 * This test will not succeed if sjinfo->ojrelid is zero, which is
-		 * possible for an antijoin that was converted from a semijoin; but in
-		 * such a case the Var couldn't have come from its nullable side.
-		 */
-		if (sjinfo->jointype == JOIN_ANTI && sjinfo->ojrelid != 0 &&
-			bms_is_member(sjinfo->ojrelid, forced_null_var->varnullingrels))
-			return true;
-	}
-
-	return false;
-}
-
 /*
  * add_base_clause_to_rel
  *		Add 'restrictinfo' as a baserestrictinfo to the base relation denoted
diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index 5931501e470..4328640b936 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -98,6 +98,7 @@ typedef struct reduce_outer_joins_pass2_state
 {
 	Relids		inner_reduced;	/* OJ relids reduced to plain inner joins */
 	List	   *partial_reduced;	/* List of partially reduced FULL joins */
+	Relids		anti_reduced;	/* OJ relids reduced to antijoins */
 } reduce_outer_joins_pass2_state;
 
 typedef struct reduce_outer_joins_partial_state
@@ -164,6 +165,9 @@ 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 void remove_redundant_nullability_quals_recurse(Node *jtnode,
+													   Relids antijoins);
+static Node *remove_redundant_nullability_quals(Node *quals, Relids antijoins);
 static bool forced_null_var_is_attnotnull(PlannerInfo *root,
 										  List *forced_null_vars,
 										  reduce_outer_joins_pass1_state *state);
@@ -3242,9 +3246,8 @@ flatten_simple_union_all(PlannerInfo *root)
  * 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.
+ * to JOIN_ANTI.  The IS NULL clause then becomes redundant, and is removed
+ * at the end of this phase; see remove_redundant_nullability_quals_recurse.
  *
  * A whole-row Var works too.  "WHERE b IS NULL" in row-format semantics is
  * true when b's whole-row value is NULL or when every column of b is NULL;
@@ -3292,6 +3295,7 @@ reduce_outer_joins(PlannerInfo *root)
 
 	state2.inner_reduced = NULL;
 	state2.partial_reduced = NIL;
+	state2.anti_reduced = NULL;
 
 	reduce_outer_joins_pass2((Node *) root->parse->jointree,
 							 state1, &state2,
@@ -3334,6 +3338,89 @@ reduce_outer_joins(PlannerInfo *root)
 								  full_join_relids,
 								  statep->unreduced_side);
 	}
+
+	/*
+	 * Finally, remove any quals made redundant by reducing outer joins to
+	 * antijoins.
+	 */
+	if (!bms_is_empty(state2.anti_reduced))
+		remove_redundant_nullability_quals_recurse((Node *) root->parse->jointree,
+												   state2.anti_reduced);
+}
+
+/*
+ * remove_redundant_nullability_quals_recurse
+ *		Remove quals made redundant by reducing outer joins to antijoins.
+ *
+ * An IS NULL qual on a Var from the nullable side of a lower antijoin is
+ * necessarily true.  Removing such quals is not just an optimization: they
+ * would generate bogus selectivity estimates, and they would retain references
+ * to rels that join removal might later delete.
+ */
+static void
+remove_redundant_nullability_quals_recurse(Node *jtnode, Relids antijoins)
+{
+	if (jtnode == NULL)
+		return;
+	if (IsA(jtnode, RangeTblRef))
+	{
+		/* nothing to do here */
+	}
+	else if (IsA(jtnode, FromExpr))
+	{
+		FromExpr   *f = (FromExpr *) jtnode;
+		ListCell   *l;
+
+		foreach(l, f->fromlist)
+			remove_redundant_nullability_quals_recurse(lfirst(l), antijoins);
+		f->quals = remove_redundant_nullability_quals(f->quals, antijoins);
+	}
+	else if (IsA(jtnode, JoinExpr))
+	{
+		JoinExpr   *j = (JoinExpr *) jtnode;
+
+		remove_redundant_nullability_quals_recurse(j->larg, antijoins);
+		remove_redundant_nullability_quals_recurse(j->rarg, antijoins);
+		j->quals = remove_redundant_nullability_quals(j->quals, antijoins);
+	}
+	else
+		elog(ERROR, "unrecognized jointree node type: %d",
+			 (int) nodeTag(jtnode));
+}
+
+/*
+ * remove_redundant_nullability_quals
+ *		Remove redundant IS NULL quals from one implicit-AND qual list.
+ */
+static Node *
+remove_redundant_nullability_quals(Node *quals, Relids antijoins)
+{
+	if (quals == NULL)
+		return NULL;
+	if (IsA(quals, List))
+	{
+		List	   *newquals = NIL;
+		ListCell   *l;
+
+		foreach(l, (List *) quals)
+		{
+			Node	   *clause = (Node *) lfirst(l);
+			Var		   *var = find_forced_null_var(clause);
+
+			if (var && bms_overlap(var->varnullingrels, antijoins))
+				continue;
+			newquals = lappend(newquals, clause);
+		}
+		quals = (Node *) newquals;
+	}
+	else
+	{
+		Var		   *var = find_forced_null_var(quals);
+
+		if (var && bms_overlap(var->varnullingrels, antijoins))
+			quals = NULL;
+	}
+	return quals;
 }
 
 /*
@@ -3449,9 +3536,11 @@ reduce_outer_joins_pass1(Node *jtnode)
  *
  * Returns info in state2 about outer joins that were successfully simplified.
  * Joins that were fully reduced to inner joins are all added to
- * state2->inner_reduced.  If a full join is reduced to a left join,
- * it needs its own entry in state2->partial_reduced, since that will
- * require custom processing to remove only the correct nullingrel markers.
+ * state2->inner_reduced, and joins that became antijoins are all added to
+ * state2->anti_reduced.  If a full join is reduced to a left join or an
+ * antijoin, it also needs its own entry in state2->partial_reduced, since
+ * that will require custom processing to remove only the correct nullingrel
+ * markers.
  */
 static void
 reduce_outer_joins_pass2(Node *jtnode,
@@ -3644,7 +3733,8 @@ reduce_outer_joins_pass2(Node *jtnode,
 
 		/*
 		 * Apply the jointype change, if any, to both jointree node and RTE.
-		 * Also, if we changed an RTE to INNER, add its RTI to inner_reduced.
+		 * Also, if we changed an RTE to INNER, add its RTI to inner_reduced;
+		 * if we changed it to ANTI, add its RTI to anti_reduced.
 		 */
 		if (rtindex && jointype != j->jointype)
 		{
@@ -3656,6 +3746,9 @@ reduce_outer_joins_pass2(Node *jtnode,
 			if (jointype == JOIN_INNER)
 				state2->inner_reduced = bms_add_member(state2->inner_reduced,
 													   rtindex);
+			else if (jointype == JOIN_ANTI)
+				state2->anti_reduced = bms_add_member(state2->anti_reduced,
+													  rtindex);
 		}
 		j->jointype = jointype;
 
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index db4fcc5a5a0..3a5255de7bf 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -4105,6 +4105,41 @@ where t2 is null;
  2
 (2 rows)
 
+-- Test that quals made redundant by reducing an outer join to an antijoin are
+-- removed from the jointree
+-- (fallout from the fix for bug #19560)
+create temp table tbl_anti_pk (a int primary key);
+-- t3.a IS NULL is redundant, and the t2/t3 join can be removed
+explain (costs off)
+select 1 from tbl_anti_pk t1 left join
+  (tbl_anti_pk t2 left join tbl_anti_pk t3 on t3.a = t2.a) on true
+where t2.a is null and t3.a is null;
+               QUERY PLAN               
+----------------------------------------
+ Nested Loop Anti Join
+   ->  Seq Scan on tbl_anti_pk t1
+   ->  Materialize
+         ->  Seq Scan on tbl_anti_pk t2
+(4 rows)
+
+-- the redundant qual can also be a degenerate ON clause of an upper join
+explain (costs off)
+select 1 from tbl_anti_pk t1 left join
+  (tbl_anti_pk t2 left join
+   (tbl_anti_pk t3 left join tbl_anti_pk t5 on t5.a = t3.a) on t3.a = t2.a)
+  on t3.a is null and t5.a is null;
+                     QUERY PLAN                     
+----------------------------------------------------
+ Nested Loop Left Join
+   ->  Seq Scan on tbl_anti_pk t1
+   ->  Materialize
+         ->  Hash Anti Join
+               Hash Cond: (t2.a = t3.a)
+               ->  Seq Scan on tbl_anti_pk t2
+               ->  Hash
+                     ->  Seq Scan on tbl_anti_pk t3
+(8 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 9533af8656e..8f1ffccdd4e 100644
--- a/src/test/regress/sql/join.sql
+++ b/src/test/regress/sql/join.sql
@@ -1187,6 +1187,24 @@ where t2 is null;
 select * from (values (1), (2)) v(x) left join tbl_zero t2 on t2 is not null
 where t2 is null;
 
+-- Test that quals made redundant by reducing an outer join to an antijoin are
+-- removed from the jointree
+-- (fallout from the fix for bug #19560)
+create temp table tbl_anti_pk (a int primary key);
+
+-- t3.a IS NULL is redundant, and the t2/t3 join can be removed
+explain (costs off)
+select 1 from tbl_anti_pk t1 left join
+  (tbl_anti_pk t2 left join tbl_anti_pk t3 on t3.a = t2.a) on true
+where t2.a is null and t3.a is null;
+
+-- the redundant qual can also be a degenerate ON clause of an upper join
+explain (costs off)
+select 1 from tbl_anti_pk t1 left join
+  (tbl_anti_pk t2 left join
+   (tbl_anti_pk t3 left join tbl_anti_pk t5 on t5.a = t3.a) on t3.a = t2.a)
+  on t3.a is null and t5.a is null;
+
 rollback;
 
 --
-- 
2.37.1 (Apple Git-137.1)

