From a5239d7d62091c81550355e14784f7003347e714 Mon Sep 17 00:00:00 2001
From: Rui Zhao <zhaorui126@gmail.com>
Date: Thu, 30 Jul 2026 23:04:14 +0800
Subject: [PATCH 2/2] Derive the non-nullable Vars once per jointree node, not
 once per NOT IN

sublink_testexpr_is_not_nullable() ran flatten_join_alias_vars() over the
whole qual list, and then find_nonnullable_vars() over the result, for
every NOT IN it was asked about.  The qual list is the entire WHERE clause
of the node, sublinks and their subqueries included, and
flatten_join_alias_vars() copies all of it, so a WHERE clause with N such
NOT INs did O(N^2) work -- all of it wasted when the proof fails, as it
must whenever the sub-select's output columns are nullable.

Pass the quals in a SafeQuals struct instead, and derive the set of Vars
they force non-null on first use, so every SubLink at the same jointree
node shares one derivation.  Also test the sub-select's output columns
before the outer expressions, since that is only a scan of its target
list.

With 320 NOT INs on nullable columns in one WHERE clause, planning goes
from 135ms back to 64ms, against 61ms on master.

Add a test for the RIGHT JOIN mirror of the ON-qual case, which had none.
---
 src/backend/optimizer/plan/subselect.c    | 60 +++++++++---------
 src/backend/optimizer/prep/prepjointree.c | 74 +++++++++++++++--------
 src/include/optimizer/subselect.h         | 16 ++++-
 src/test/regress/expected/subselect.out   | 18 ++++++
 src/test/regress/sql/subselect.sql        |  6 ++
 5 files changed, 119 insertions(+), 55 deletions(-)

diff --git a/src/backend/optimizer/plan/subselect.c b/src/backend/optimizer/plan/subselect.c
index 94b6a2d8f6..38e06f2593 100644
--- a/src/backend/optimizer/plan/subselect.c
+++ b/src/backend/optimizer/plan/subselect.c
@@ -93,7 +93,7 @@ static bool contain_outer_selfref_walker(Node *node, Index *depth);
 static void inline_cte(PlannerInfo *root, CommonTableExpr *cte);
 static bool inline_cte_walker(Node *node, inline_cte_walker_context *context);
 static bool sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink,
-											 List *nonnullable_quals);
+											 SafeQuals *safe_quals);
 static bool simplify_EXISTS_query(PlannerInfo *root, Query *query);
 static Query *convert_EXISTS_to_ANY(PlannerInfo *root, Query *subselect,
 									Node **testexpr, List **paramIds);
@@ -1320,10 +1320,10 @@ convert_VALUES_to_ANY(PlannerInfo *root, Node *testexpr, Query *values)
  * The conversion must fail if the converted qual would reference any but
  * these parent-query relids.
  *
- * nonnullable_quals is a list of qual clauses that are guaranteed to filter
- * the rows on which the SubLink is evaluated.  We only need it for the
- * under_not case, where it may help prove that the left-hand expressions are
- * non-nullable.  It may be NIL, in which case no such proof is attempted.
+ * safe_quals describes the qual clauses that are guaranteed to filter the rows
+ * on which the SubLink is evaluated.  We only need it for the under_not case,
+ * where it may help prove that the left-hand expressions are non-nullable.  It
+ * may be NULL, in which case no such proof is attempted.
  *
  * On success, the returned JoinExpr has larg = NULL and rarg = the jointree
  * item representing the pulled-up subquery.  The caller must set larg to
@@ -1347,7 +1347,7 @@ convert_VALUES_to_ANY(PlannerInfo *root, Node *testexpr, Query *values)
 JoinExpr *
 convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink,
 							bool under_not, Relids available_rels,
-							List *nonnullable_quals)
+							SafeQuals *safe_quals)
 {
 	JoinExpr   *result;
 	Query	   *parse = root->parse;
@@ -1372,10 +1372,14 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink,
 	 * output columns can be NULL, and further that the operator itself cannot
 	 * return NULL for non-null inputs, then the logic is identical and it's
 	 * safe to convert NOT IN to an anti-join.
+	 *
+	 * Test the sub-select's output columns first: that's just a scan of its
+	 * target list, whereas proving the outer expressions non-nullable may have
+	 * to analyze the whole qual list.
 	 */
 	if (under_not &&
-		(!sublink_testexpr_is_not_nullable(root, sublink, nonnullable_quals) ||
-		 !query_outputs_are_not_nullable(subselect)))
+		(!query_outputs_are_not_nullable(subselect) ||
+		 !sublink_testexpr_is_not_nullable(root, sublink, safe_quals)))
 		return NULL;
 
 	/*
@@ -1481,12 +1485,13 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink,
  *
  * An outer expression is provably non-nullable if it is a column with a NOT
  * NULL constraint, or more generally any expression that expr_is_nonnullable
- * accepts.  In addition, a plain Var can be proven non-nullable if some clause
- * in nonnullable_quals forces it non-null (for example "x IS NOT NULL" or a
- * strict comparison on x).  Those quals were collected by the caller from the
- * jointree at or below the point where this NOT IN is evaluated, and are
- * guaranteed to remove any NULL-valued rows before they can affect the result
- * of the anti-join, so the NOT IN to anti-join conversion stays valid.
+ * accepts.  In addition, a plain Var of the current query level can be proven
+ * non-nullable if some clause in safe_quals forces it non-null (for example
+ * "x IS NOT NULL" or a strict comparison on x).  Those quals were collected by
+ * the caller from the jointree at or below the point where this NOT IN is
+ * evaluated, and are guaranteed to remove any NULL-valued rows before they can
+ * affect the result of the anti-join, so the NOT IN to anti-join conversion
+ * stays valid.
  *
  * We handle the three standard parser representations for ANY sublinks: a
  * single OpExpr for single-column comparisons, a BoolExpr containing a list of
@@ -1500,12 +1505,10 @@ convert_ANY_sublink_to_join(PlannerInfo *root, SubLink *sublink,
  */
 static bool
 sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink,
-								 List *nonnullable_quals)
+								 SafeQuals *safe_quals)
 {
 	Node	   *testexpr = sublink->testexpr;
 	List	   *outer_exprs = NIL;
-	List	   *nonnullable_vars = NIL;
-	bool		computed_nonnullable_vars = false;
 
 	/* Punt if sublink is not in the expected format */
 	if (sublink->subLinkType != ANY_SUBLINK || testexpr == NULL)
@@ -1596,10 +1599,10 @@ sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink,
 		/*
 		 * For a plain Var, even if that didn't work, we can still prove it
 		 * non-nullable if find_nonnullable_vars can find a "var IS NOT NULL"
-		 * or similarly strict condition among nonnullable_quals.  Those are
-		 * the quals the caller determined are guaranteed to filter the rows
-		 * on which this SubLink is evaluated.  Compute the list of Vars they
-		 * force non-null if we didn't already.
+		 * or similarly strict condition among the safe quals.  Those are the
+		 * quals the caller determined are guaranteed to filter the rows on
+		 * which this SubLink is evaluated.  Derive the set of Vars they force
+		 * non-null if that hasn't been done yet for this jointree node.
 		 *
 		 * find_nonnullable_vars only reports Vars of the current query level,
 		 * and identifies them by varno and varattno alone, so an upper-level
@@ -1610,25 +1613,26 @@ sublink_testexpr_is_not_nullable(PlannerInfo *root, SubLink *sublink,
 		 * just as the outer expressions were above, so that the Vars match
 		 * up.
 		 */
-		if (nonnullable_quals != NIL && IsA(expr, Var) &&
-			((Var *) expr)->varlevelsup == 0)
+		if (safe_quals != NULL && safe_quals->quals != NIL &&
+			IsA(expr, Var) && ((Var *) expr)->varlevelsup == 0)
 		{
 			Var		   *var = (Var *) expr;
 
-			if (!computed_nonnullable_vars)
+			if (!safe_quals->computed)
 			{
 				List	   *flat_quals;
 
 				flat_quals = (List *)
 					flatten_join_alias_vars(root, root->parse,
-											(Node *) nonnullable_quals);
-				nonnullable_vars = find_nonnullable_vars((Node *) flat_quals);
-				computed_nonnullable_vars = true;
+											(Node *) safe_quals->quals);
+				safe_quals->nonnullable_vars =
+					find_nonnullable_vars((Node *) flat_quals);
+				safe_quals->computed = true;
 			}
 
 			if (mbms_is_member(var->varno,
 							   var->varattno - FirstLowInvalidHeapAttributeNumber,
-							   nonnullable_vars))
+							   safe_quals->nonnullable_vars))
 				continue;
 		}
 
diff --git a/src/backend/optimizer/prep/prepjointree.c b/src/backend/optimizer/prep/prepjointree.c
index d2c132fcbe..c52e4bd6df 100644
--- a/src/backend/optimizer/prep/prepjointree.c
+++ b/src/backend/optimizer/prep/prepjointree.c
@@ -108,12 +108,13 @@ typedef struct reduce_outer_joins_partial_state
 static Query *expand_virtual_generated_columns(PlannerInfo *root, Query *parse,
 											   RangeTblEntry *rte, int rt_index,
 											   Relation relation);
+static void init_safe_quals(SafeQuals *sq, List *quals);
 static Node *pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 											   Relids *relids, List **safe_quals);
 static Node *pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 										   Node **jtlink1, Relids available_rels1,
 										   Node **jtlink2, Relids available_rels2,
-										   List *nonnullable_quals);
+										   SafeQuals *safe_quals);
 static Node *pull_up_subqueries_recurse(PlannerInfo *root, Node *jtnode,
 										JoinExpr *lowest_outer_join,
 										AppendRelInfo *containing_appendrel);
@@ -691,6 +692,21 @@ pull_up_sublinks(PlannerInfo *root)
 		root->parse->jointree = makeFromExpr(list_make1(jtnode), NULL);
 }
 
+/*
+ * Set up a SafeQuals for the given qual list.
+ *
+ * The set of Vars the quals force non-null is derived lazily, on the first
+ * NOT IN that actually needs it, and then shared by every SubLink at this
+ * jointree node.
+ */
+static void
+init_safe_quals(SafeQuals *sq, List *quals)
+{
+	sq->quals = quals;
+	sq->nonnullable_vars = NIL;
+	sq->computed = false;
+}
+
 /*
  * Recurse through jointree nodes for pull_up_sublinks()
  *
@@ -727,6 +743,7 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 		List	   *newfromlist = NIL;
 		Relids		frelids = NULL;
 		List	   *fsafequals = NIL;
+		SafeQuals	fsq;
 		FromExpr   *newf;
 		Node	   *jtlink;
 		ListCell   *l;
@@ -758,10 +775,11 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 		/* Set up a link representing the rebuilt jointree */
 		jtlink = (Node *) newf;
 		/* Now process qual --- all children are available for use */
+		init_safe_quals(&fsq, fsafequals);
 		newf->quals = pull_up_sublinks_qual_recurse(root, f->quals,
 													&jtlink, frelids,
 													NULL, NULL,
-													fsafequals);
+													&fsq);
 
 		/*
 		 * Note that the result will be either newf, or a stack of JoinExprs
@@ -784,6 +802,7 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 		List	   *leftsafequals;
 		List	   *rightsafequals;
 		List	   *passquals;
+		SafeQuals	jsq;
 		Node	   *jtlink;
 
 		/*
@@ -827,12 +846,13 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 				if (j->quals)
 					passquals = lappend(passquals, j->quals);
 
+				init_safe_quals(&jsq, passquals);
 				j->quals = pull_up_sublinks_qual_recurse(root, j->quals,
 														 &jtlink,
 														 bms_union(leftrelids,
 																   rightrelids),
 														 NULL, NULL,
-														 passquals);
+														 &jsq);
 
 				*safe_quals = passquals;
 				break;
@@ -849,11 +869,12 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 				if (j->quals)
 					passquals = lappend(passquals, j->quals);
 
+				init_safe_quals(&jsq, passquals);
 				j->quals = pull_up_sublinks_qual_recurse(root, j->quals,
 														 &j->rarg,
 														 rightrelids,
 														 NULL, NULL,
-														 passquals);
+														 &jsq);
 
 				*safe_quals = leftsafequals;
 				break;
@@ -867,11 +888,12 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
 				if (j->quals)
 					passquals = lappend(passquals, j->quals);
 
+				init_safe_quals(&jsq, passquals);
 				j->quals = pull_up_sublinks_qual_recurse(root, j->quals,
 														 &j->larg,
 														 leftrelids,
 														 NULL, NULL,
-														 passquals);
+														 &jsq);
 
 				*safe_quals = rightsafequals;
 				break;
@@ -913,13 +935,13 @@ pull_up_sublinks_jointree_recurse(PlannerInfo *root, Node *jtnode,
  * and/or jtlink2 in the order we encounter them.  We rely on subsequent
  * optimization to rearrange the stack if appropriate.
  *
- * nonnullable_quals is the qual list we hand to convert_ANY_sublink_to_join
- * when trying to convert a NOT IN against available_rels1; see there for what
- * it means.  These quals are known to apply to the rows at this node, so we
- * pass them down unchanged through AND clauses (whose arms share that
- * context), but pass NIL when recursing into a just-pulled-up SubLink's quals,
- * or when converting against available_rels2, since those are evaluated on
- * different rows that the quals say nothing about.
+ * safe_quals describes the quals we hand to convert_ANY_sublink_to_join when
+ * trying to convert a NOT IN against available_rels1; see there for what it
+ * means.  These quals are known to apply to the rows at this node, so we pass
+ * them down unchanged through AND clauses (whose arms share that context), but
+ * pass NULL when recursing into a just-pulled-up SubLink's quals, or when
+ * converting against available_rels2, since those are evaluated on different
+ * rows that the quals say nothing about.
  *
  * Returns the replacement qual node, or NULL if the qual should be removed.
  */
@@ -927,7 +949,7 @@ static Node *
 pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 							  Node **jtlink1, Relids available_rels1,
 							  Node **jtlink2, Relids available_rels2,
-							  List *nonnullable_quals)
+							  SafeQuals *safe_quals)
 {
 	if (node == NULL)
 		return NULL;
@@ -955,7 +977,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 			}
 
 			if ((j = convert_ANY_sublink_to_join(root, sublink, false,
-												 available_rels1, NIL)) != NULL)
+												 available_rels1, NULL)) != NULL)
 			{
 				/* Yes; insert the new join node into the join tree */
 				j->larg = *jtlink1;
@@ -977,13 +999,13 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 														 available_rels1,
 														 &j->rarg,
 														 child_rels,
-														 NIL);
+														 NULL);
 				/* Return NULL representing constant TRUE */
 				return NULL;
 			}
 			if (available_rels2 != NULL &&
 				(j = convert_ANY_sublink_to_join(root, sublink, false,
-												 available_rels2, NIL)) != NULL)
+												 available_rels2, NULL)) != NULL)
 			{
 				/* Yes; insert the new join node into the join tree */
 				j->larg = *jtlink2;
@@ -1005,7 +1027,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 														 available_rels2,
 														 &j->rarg,
 														 child_rels,
-														 NIL);
+														 NULL);
 				/* Return NULL representing constant TRUE */
 				return NULL;
 			}
@@ -1035,7 +1057,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 														 available_rels1,
 														 &j->rarg,
 														 child_rels,
-														 NIL);
+														 NULL);
 				/* Return NULL representing constant TRUE */
 				return NULL;
 			}
@@ -1063,7 +1085,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 														 available_rels2,
 														 &j->rarg,
 														 child_rels,
-														 NIL);
+														 NULL);
 				/* Return NULL representing constant TRUE */
 				return NULL;
 			}
@@ -1085,7 +1107,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 			{
 				if ((j = convert_ANY_sublink_to_join(root, sublink, true,
 													 available_rels1,
-													 nonnullable_quals)) != NULL)
+													 safe_quals)) != NULL)
 				{
 					/* Yes; insert the new join node into the join tree */
 					j->larg = *jtlink1;
@@ -1107,14 +1129,14 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 															 &j->rarg,
 															 child_rels,
 															 NULL, NULL,
-															 NIL);
+															 NULL);
 					/* Return NULL representing constant TRUE */
 					return NULL;
 				}
 				if (available_rels2 != NULL &&
 					(j = convert_ANY_sublink_to_join(root, sublink, true,
 													 available_rels2,
-													 NIL)) != NULL)
+													 NULL)) != NULL)
 				{
 					/* Yes; insert the new join node into the join tree */
 					j->larg = *jtlink2;
@@ -1136,7 +1158,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 															 &j->rarg,
 															 child_rels,
 															 NULL, NULL,
-															 NIL);
+															 NULL);
 					/* Return NULL representing constant TRUE */
 					return NULL;
 				}
@@ -1166,7 +1188,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 															 &j->rarg,
 															 child_rels,
 															 NULL, NULL,
-															 NIL);
+															 NULL);
 					/* Return NULL representing constant TRUE */
 					return NULL;
 				}
@@ -1194,7 +1216,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 															 &j->rarg,
 															 child_rels,
 															 NULL, NULL,
-															 NIL);
+															 NULL);
 					/* Return NULL representing constant TRUE */
 					return NULL;
 				}
@@ -1220,7 +1242,7 @@ pull_up_sublinks_qual_recurse(PlannerInfo *root, Node *node,
 													  available_rels1,
 													  jtlink2,
 													  available_rels2,
-													  nonnullable_quals);
+													  safe_quals);
 			if (newclause)
 				newclauses = lappend(newclauses, newclause);
 		}
diff --git a/src/include/optimizer/subselect.h b/src/include/optimizer/subselect.h
index f3d4a2138b..bc7c194091 100644
--- a/src/include/optimizer/subselect.h
+++ b/src/include/optimizer/subselect.h
@@ -16,6 +16,20 @@
 #include "nodes/pathnodes.h"
 #include "nodes/plannodes.h"
 
+/*
+ * Quals that are guaranteed to filter the rows a SubLink is evaluated on,
+ * along with the set of Vars they force non-null.  The latter is expensive
+ * to derive and most SubLinks never need it, so it's computed on first use
+ * and then shared by every SubLink at the same jointree node.
+ */
+typedef struct SafeQuals
+{
+	List	   *quals;			/* qual clauses, implicitly ANDed */
+	List	   *nonnullable_vars;	/* multibitmapset of the Vars they force
+									 * non-null; valid only if computed */
+	bool		computed;		/* has nonnullable_vars been derived yet? */
+} SafeQuals;
+
 extern void SS_process_ctes(PlannerInfo *root);
 extern ScalarArrayOpExpr *convert_VALUES_to_ANY(PlannerInfo *root,
 												Node *testexpr,
@@ -24,7 +38,7 @@ extern JoinExpr *convert_ANY_sublink_to_join(PlannerInfo *root,
 											 SubLink *sublink,
 											 bool under_not,
 											 Relids available_rels,
-											 List *nonnullable_quals);
+											 SafeQuals *safe_quals);
 extern JoinExpr *convert_EXISTS_sublink_to_join(PlannerInfo *root,
 												SubLink *sublink,
 												bool under_not,
diff --git a/src/test/regress/expected/subselect.out b/src/test/regress/expected/subselect.out
index bfba1d532f..87301dc568 100644
--- a/src/test/regress/expected/subselect.out
+++ b/src/test/regress/expected/subselect.out
@@ -3682,6 +3682,24 @@ ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab);
                      ->  Seq Scan on not_null_tab
 (9 rows)
 
+-- Same, with the sides of the outer join swapped
+EXPLAIN (COSTS OFF)
+SELECT * FROM null_tab t2
+RIGHT JOIN not_null_tab t1
+ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab);
+                     QUERY PLAN                      
+-----------------------------------------------------
+ Nested Loop Left Join
+   ->  Seq Scan on not_null_tab t1
+   ->  Materialize
+         ->  Hash Anti Join
+               Hash Cond: (t2.val = not_null_tab.id)
+               ->  Seq Scan on null_tab t2
+                     Filter: (val > 0)
+               ->  Hash
+                     ->  Seq Scan on not_null_tab
+(9 rows)
+
 -- ANTI JOIN: outer side is forced non-nullable by the inner join's ON qual
 EXPLAIN (COSTS OFF)
 SELECT * FROM null_tab t1 LEFT JOIN
diff --git a/src/test/regress/sql/subselect.sql b/src/test/regress/sql/subselect.sql
index 4d00786ee3..e6327ffe5f 100644
--- a/src/test/regress/sql/subselect.sql
+++ b/src/test/regress/sql/subselect.sql
@@ -1617,6 +1617,12 @@ SELECT * FROM not_null_tab t1
 LEFT JOIN null_tab t2
 ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab);
 
+-- Same, with the sides of the outer join swapped
+EXPLAIN (COSTS OFF)
+SELECT * FROM null_tab t2
+RIGHT JOIN not_null_tab t1
+ON t2.val > 0 AND t2.val NOT IN (SELECT id FROM not_null_tab);
+
 -- ANTI JOIN: outer side is forced non-nullable by the inner join's ON qual
 EXPLAIN (COSTS OFF)
 SELECT * FROM null_tab t1 LEFT JOIN
-- 
2.43.7

