From 115a98832ddcb71b6b8e7d02e569b3aba3afcb9f Mon Sep 17 00:00:00 2001
From: Richard Guo <guofenglinux@gmail.com>
Date: Thu, 2 Apr 2026 14:54:44 +0900
Subject: [PATCH v2] Strip PlaceHolderVars from partition pruning operands
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

When pulling up a subquery, its targetlist items may be wrapped in
PlaceHolderVars to enforce separate identity or as a result of outer
joins.  This causes any upper-level WHERE clauses referencing these
outputs to contain PlaceHolderVars, which prevents partprune.c from
recognizing that they match partition key columns, defeating partition
pruning.

To fix, strip PlaceHolderVars from operands before comparing them to
partition keys.  A PlaceHolderVar with empty phnullingrels appearing
in a relation-scan-level expression is effectively a no-op, so
stripping it is safe.  This parallels the existing treatment in
indxpath.c for index matching.

In passing, rename strip_phvs_in_index_operand() to strip_noop_phvs()
and move it from indxpath.c to placeholder.c, since it is now a
general-purpose utility used by both index matching and partition
pruning code.

Back-patch to v18.  Although this issue exists before that, changes in
that version made it common enough to notice.  Given the lack of field
reports for older versions, I am not back-patching further.  In the
v18 back-patch, strip_phvs_in_index_operand() is retained as a thin
wrapper around the new strip_noop_phvs() to avoid breaking third-party
extensions that may reference it.

Reported-by: Cándido Antonio Martínez Descalzo <candido@ninehq.com>
Diagnosed-by: David Rowley <dgrowleyml@gmail.com>
Author: Richard Guo <guofenglinux@gmail.com>
Discussion: https://postgr.es/m/CAH5YaUwVUWETTyVECTnhs7C=CVwi+uMSQH=cOkwAUqMdvXdwWA@mail.gmail.com
Backpatch-through: 18
---
 src/backend/optimizer/path/indxpath.c         |  86 +-----------
 src/backend/optimizer/plan/createplan.c       |   2 +-
 src/backend/optimizer/util/placeholder.c      |  91 ++++++++++++
 src/backend/partitioning/partprune.c          |  28 +++-
 src/include/optimizer/placeholder.h           |   1 +
 src/test/regress/expected/partition_prune.out | 132 ++++++++++++++++++
 src/test/regress/sql/partition_prune.sql      |  71 ++++++++++
 7 files changed, 324 insertions(+), 87 deletions(-)

diff --git a/src/backend/optimizer/path/indxpath.c b/src/backend/optimizer/path/indxpath.c
index 4fe8e5164c7..99caadb72b4 100644
--- a/src/backend/optimizer/path/indxpath.c
+++ b/src/backend/optimizer/path/indxpath.c
@@ -31,6 +31,7 @@
 #include "optimizer/optimizer.h"
 #include "optimizer/pathnode.h"
 #include "optimizer/paths.h"
+#include "optimizer/placeholder.h"
 #include "optimizer/prep.h"
 #include "optimizer/restrictinfo.h"
 #include "utils/lsyscache.h"
@@ -196,8 +197,6 @@ static Expr *match_clause_to_ordering_op(IndexOptInfo *index,
 static bool ec_member_matches_indexcol(PlannerInfo *root, RelOptInfo *rel,
 									   EquivalenceClass *ec, EquivalenceMember *em,
 									   void *arg);
-static bool contain_strippable_phv_walker(Node *node, void *context);
-static Node *strip_phvs_in_index_operand_mutator(Node *node, void *context);
 
 
 /*
@@ -4422,7 +4421,7 @@ match_index_to_operand(Node *operand,
 	 * a subtree) has been wrapped in PlaceHolderVars to enforce separate
 	 * identity or as a result of outer joins.
 	 */
-	operand = strip_phvs_in_index_operand(operand);
+	operand = strip_noop_phvs(operand);
 
 	/*
 	 * Ignore any RelabelType node above the operand.  This is needed to be
@@ -4488,88 +4487,15 @@ match_index_to_operand(Node *operand,
 
 /*
  * strip_phvs_in_index_operand
- *	  Strip PlaceHolderVar nodes from the given operand expression to
- *	  facilitate matching against an index's key.
  *
- * A PlaceHolderVar appearing in a relation-scan-level expression is
- * effectively a no-op.  Nevertheless, to play it safe, we strip only
- * PlaceHolderVars that are not marked nullable.
- *
- * The removal is performed recursively because PlaceHolderVars can be nested
- * or interleaved with other node types.  We must peel back all layers to
- * expose the base operand.
- *
- * As a performance optimization, we first use a lightweight walker to check
- * for the presence of strippable PlaceHolderVars.  The expensive mutator is
- * invoked only if a candidate is found, avoiding unnecessary memory allocation
- * and tree copying in the common case where no PlaceHolderVars are present.
+ * Retained as a backward-compatibility wrapper around strip_noop_phvs() to
+ * avoid breaking third-party extensions that may reference this function.  New
+ * code should call strip_noop_phvs() directly.
  */
 Node *
 strip_phvs_in_index_operand(Node *operand)
 {
-	/* Don't mutate/copy if no target PHVs exist */
-	if (!contain_strippable_phv_walker(operand, NULL))
-		return operand;
-
-	return strip_phvs_in_index_operand_mutator(operand, NULL);
-}
-
-/*
- * contain_strippable_phv_walker
- *	  Detect if there are any PlaceHolderVars in the tree that are candidates
- *	  for stripping.
- *
- * We identify a PlaceHolderVar as strippable only if its phnullingrels is
- * empty.
- */
-static bool
-contain_strippable_phv_walker(Node *node, void *context)
-{
-	if (node == NULL)
-		return false;
-
-	if (IsA(node, PlaceHolderVar))
-	{
-		PlaceHolderVar *phv = (PlaceHolderVar *) node;
-
-		if (bms_is_empty(phv->phnullingrels))
-			return true;
-	}
-
-	return expression_tree_walker(node, contain_strippable_phv_walker,
-								  context);
-}
-
-/*
- * strip_phvs_in_index_operand_mutator
- *	  Recursively remove PlaceHolderVars in the tree that match the criteria.
- *
- * We strip a PlaceHolderVar only if its phnullingrels is empty, replacing it
- * with its contained expression.
- */
-static Node *
-strip_phvs_in_index_operand_mutator(Node *node, void *context)
-{
-	if (node == NULL)
-		return NULL;
-
-	if (IsA(node, PlaceHolderVar))
-	{
-		PlaceHolderVar *phv = (PlaceHolderVar *) node;
-
-		/* If matches the criteria, strip it */
-		if (bms_is_empty(phv->phnullingrels))
-		{
-			/* Recurse on its contained expression */
-			return strip_phvs_in_index_operand_mutator((Node *) phv->phexpr,
-													   context);
-		}
-
-		/* Otherwise, keep this PHV but check its contained expression */
-	}
-
-	return expression_tree_mutator(node, strip_phvs_in_index_operand_mutator,
-								   context);
+	return strip_noop_phvs(operand);
 }
 
 /*
diff --git a/src/backend/optimizer/plan/createplan.c b/src/backend/optimizer/plan/createplan.c
index 06128a9f243..10b7358c28b 100644
--- a/src/backend/optimizer/plan/createplan.c
+++ b/src/backend/optimizer/plan/createplan.c
@@ -5271,7 +5271,7 @@ fix_indexqual_operand(Node *node, IndexOptInfo *index, int indexcol)
 	/*
 	 * Remove any PlaceHolderVar wrapping of the indexkey
 	 */
-	node = strip_phvs_in_index_operand(node);
+	node = strip_noop_phvs(node);
 
 	/*
 	 * Remove any binary-compatible relabeling of the indexkey
diff --git a/src/backend/optimizer/util/placeholder.c b/src/backend/optimizer/util/placeholder.c
index e1cd00a72fb..86e3c1e751f 100644
--- a/src/backend/optimizer/util/placeholder.c
+++ b/src/backend/optimizer/util/placeholder.c
@@ -35,6 +35,8 @@ static void find_placeholders_recurse(PlannerInfo *root, Node *jtnode);
 static void find_placeholders_in_expr(PlannerInfo *root, Node *expr);
 static bool contain_placeholder_references_walker(Node *node,
 												  contain_placeholder_references_context *context);
+static bool contain_noop_phv_walker(Node *node, void *context);
+static Node *strip_noop_phvs_mutator(Node *node, void *context);
 
 
 /*
@@ -585,3 +587,92 @@ get_placeholder_nulling_relids(PlannerInfo *root, PlaceHolderInfo *phinfo)
 	result = bms_del_members(result, phinfo->ph_eval_at);
 	return result;
 }
+
+/*
+ * strip_noop_phvs
+ *	  Strip no-op PlaceHolderVar nodes from the given expression tree.
+ *
+ * A PlaceHolderVar that is not marked as nullable (i.e., its phnullingrels
+ * is empty) is effectively a no-op when it appears in a relation-scan-level
+ * expression.  This function strips such PlaceHolderVars, which is useful
+ * for matching expressions to index keys or partition keys in cases where
+ * the expression has been wrapped in PlaceHolderVars during subquery pullup.
+ *
+ * IMPORTANT: the caller must ensure that the expression is a scan-level
+ * expression, so that non-nullable PlaceHolderVars in it are indeed no-ops.
+ *
+ * The removal is performed recursively because PlaceHolderVars can be nested
+ * or interleaved with other node types.  We must peel back all layers to
+ * expose the base expression.
+ *
+ * As a performance optimization, we first use a lightweight walker to check
+ * for the presence of strippable PlaceHolderVars.  The expensive mutator is
+ * invoked only if a candidate is found, avoiding unnecessary memory allocation
+ * and tree copying in the common case where no PlaceHolderVars are present.
+ */
+Node *
+strip_noop_phvs(Node *node)
+{
+	/* Don't mutate/copy if no target PHVs exist */
+	if (!contain_noop_phv_walker(node, NULL))
+		return node;
+
+	return strip_noop_phvs_mutator(node, NULL);
+}
+
+/*
+ * contain_noop_phv_walker
+ *	  Detect if there are any PlaceHolderVars in the tree that are candidates
+ *	  for stripping.
+ *
+ * We identify a PlaceHolderVar as strippable only if its phnullingrels is
+ * empty.
+ */
+static bool
+contain_noop_phv_walker(Node *node, void *context)
+{
+	if (node == NULL)
+		return false;
+
+	if (IsA(node, PlaceHolderVar))
+	{
+		PlaceHolderVar *phv = (PlaceHolderVar *) node;
+
+		if (bms_is_empty(phv->phnullingrels))
+			return true;
+	}
+
+	return expression_tree_walker(node, contain_noop_phv_walker,
+								  context);
+}
+
+/*
+ * strip_noop_phvs_mutator
+ *	  Recursively remove PlaceHolderVars that are not marked nullable.
+ *
+ * We strip a PlaceHolderVar only if its phnullingrels is empty, replacing it
+ * with its contained expression.
+ */
+static Node *
+strip_noop_phvs_mutator(Node *node, void *context)
+{
+	if (node == NULL)
+		return NULL;
+
+	if (IsA(node, PlaceHolderVar))
+	{
+		PlaceHolderVar *phv = (PlaceHolderVar *) node;
+
+		if (bms_is_empty(phv->phnullingrels))
+		{
+			/* Recurse on its contained expression */
+			return strip_noop_phvs_mutator((Node *) phv->phexpr,
+										   context);
+		}
+
+		/* Otherwise, keep this PHV but check its contained expression */
+	}
+
+	return expression_tree_mutator(node, strip_noop_phvs_mutator,
+								   context);
+}
diff --git a/src/backend/partitioning/partprune.c b/src/backend/partitioning/partprune.c
index 48a35f763e9..2ef98d6ad6e 100644
--- a/src/backend/partitioning/partprune.c
+++ b/src/backend/partitioning/partprune.c
@@ -49,6 +49,7 @@
 #include "optimizer/cost.h"
 #include "optimizer/optimizer.h"
 #include "optimizer/pathnode.h"
+#include "optimizer/placeholder.h"
 #include "parser/parsetree.h"
 #include "partitioning/partbounds.h"
 #include "partitioning/partprune.h"
@@ -1814,6 +1815,15 @@ gen_prune_steps_from_opexps(GeneratePruningStepsContext *context,
  *   and couldn't possibly match any other one either, due to its form or
  *   properties (such as containing a volatile function).
  *   Output arguments: none set.
+ *
+ * Note that when pulling up a subquery, the clause operands may get wrapped
+ * in PlaceHolderVars to enforce separate identity or as a result of outer
+ * joins.  We must strip such no-op PlaceHolderVars before comparing operands
+ * to the partition key, otherwise the equal() checks will fail to recognize
+ * valid matches.  This is safe because the clauses here are always
+ * relation-scan-level expressions, where a PlaceHolderVar with empty
+ * phnullingrels is effectively a no-op.  Stripping may also bring separate
+ * RelabelType nodes into adjacency, so we must loop when peeling those.
  */
 static PartClauseMatchStatus
 match_clause_to_partition_key(GeneratePruningStepsContext *context,
@@ -1929,10 +1939,12 @@ match_clause_to_partition_key(GeneratePruningStepsContext *context,
 		PartClauseInfo *partclause;
 
 		leftop = (Expr *) get_leftop(clause);
-		if (IsA(leftop, RelabelType))
+		leftop = (Expr *) strip_noop_phvs((Node *) leftop);
+		while (IsA(leftop, RelabelType))
 			leftop = ((RelabelType *) leftop)->arg;
 		rightop = (Expr *) get_rightop(clause);
-		if (IsA(rightop, RelabelType))
+		rightop = (Expr *) strip_noop_phvs((Node *) rightop);
+		while (IsA(rightop, RelabelType))
 			rightop = ((RelabelType *) rightop)->arg;
 		opno = opclause->opno;
 
@@ -2180,7 +2192,8 @@ match_clause_to_partition_key(GeneratePruningStepsContext *context,
 				   *elem_clauses;
 		ListCell   *lc1;
 
-		if (IsA(leftop, RelabelType))
+		leftop = (Expr *) strip_noop_phvs((Node *) leftop);
+		while (IsA(leftop, RelabelType))
 			leftop = ((RelabelType *) leftop)->arg;
 
 		/* check if the LHS matches this partition key */
@@ -2406,7 +2419,8 @@ match_clause_to_partition_key(GeneratePruningStepsContext *context,
 		NullTest   *nulltest = (NullTest *) clause;
 		Expr	   *arg = nulltest->arg;
 
-		if (IsA(arg, RelabelType))
+		arg = (Expr *) strip_noop_phvs((Node *) arg);
+		while (IsA(arg, RelabelType))
 			arg = ((RelabelType *) arg)->arg;
 
 		/* Does arg match with this partition key column? */
@@ -3718,7 +3732,8 @@ match_boolean_partition_clause(Oid partopfamily, Expr *clause, Expr *partkey,
 		BooleanTest *btest = (BooleanTest *) clause;
 
 		leftop = btest->arg;
-		if (IsA(leftop, RelabelType))
+		leftop = (Expr *) strip_noop_phvs((Node *) leftop);
+		while (IsA(leftop, RelabelType))
 			leftop = ((RelabelType *) leftop)->arg;
 
 		if (equal(leftop, partkey))
@@ -3755,7 +3770,8 @@ match_boolean_partition_clause(Oid partopfamily, Expr *clause, Expr *partkey,
 
 		leftop = is_not_clause ? get_notclausearg(clause) : clause;
 
-		if (IsA(leftop, RelabelType))
+		leftop = (Expr *) strip_noop_phvs((Node *) leftop);
+		while (IsA(leftop, RelabelType))
 			leftop = ((RelabelType *) leftop)->arg;
 
 		/* Compare to the partition key, and make up a clause ... */
diff --git a/src/include/optimizer/placeholder.h b/src/include/optimizer/placeholder.h
index db92d8861ba..0186f18bb06 100644
--- a/src/include/optimizer/placeholder.h
+++ b/src/include/optimizer/placeholder.h
@@ -32,5 +32,6 @@ extern bool contain_placeholder_references_to(PlannerInfo *root, Node *clause,
 											  int relid);
 extern Relids get_placeholder_nulling_relids(PlannerInfo *root,
 											 PlaceHolderInfo *phinfo);
+extern Node *strip_noop_phvs(Node *node);
 
 #endif							/* PLACEHOLDER_H */
diff --git a/src/test/regress/expected/partition_prune.out b/src/test/regress/expected/partition_prune.out
index d1966cd7d82..7aa3e5cbaf9 100644
--- a/src/test/regress/expected/partition_prune.out
+++ b/src/test/regress/expected/partition_prune.out
@@ -4801,3 +4801,135 @@ select min(a) over (partition by a order by a) from part_abc where a >= stable_o
 
 drop view part_abc_view;
 drop table part_abc;
+--
+-- Check that operands wrapped in PlaceHolderVars are matched to partition
+-- keys, allowing partition pruning to occur.  PlaceHolderVars can be
+-- introduced when a subquery's output is used with grouping sets.
+--
+create table phv_part (a int, b text) partition by list (a);
+create table phv_part_1 partition of phv_part for values in (1);
+create table phv_part_2 partition of phv_part for values in (2);
+create table phv_part_null partition of phv_part for values in (null);
+insert into phv_part values (1, 'one'), (2, 'two'), (null, 'null');
+-- OpExpr: PHV-wrapped operand matched via equal()
+explain (costs off)
+select * from (select a, b from phv_part) t
+  where a = 1
+  group by grouping sets (a, b);
+              QUERY PLAN               
+---------------------------------------
+ MixedAggregate
+   Hash Key: phv_part.b
+   Group Key: phv_part.a
+   ->  Seq Scan on phv_part_1 phv_part
+         Filter: (a = 1)
+(5 rows)
+
+select * from (select a, b from phv_part) t
+  where a = 1
+  group by grouping sets (a, b);
+ a |  b  
+---+-----
+ 1 | 
+   | one
+(2 rows)
+
+-- OpExpr with RelabelType: PHV wrapped around a casted column
+explain (costs off)
+select * from (select a::oid as x, b from phv_part) t
+  where x::int = 1
+  group by grouping sets (x, b);
+                QUERY PLAN                 
+-------------------------------------------
+ HashAggregate
+   Hash Key: (phv_part.a)::oid
+   Hash Key: phv_part.b
+   ->  Seq Scan on phv_part_1 phv_part
+         Filter: (((a)::oid)::integer = 1)
+(5 rows)
+
+select * from (select a::oid as x, b from phv_part) t
+  where x::int = 1
+  group by grouping sets (x, b);
+ x |  b  
+---+-----
+ 1 | 
+   | one
+(2 rows)
+
+-- ScalarArrayOpExpr: IN clause with PHV-wrapped operand
+explain (costs off)
+select * from (select a, b from phv_part) t
+  where a in (1, null)
+  group by grouping sets (a, b);
+                    QUERY PLAN                     
+---------------------------------------------------
+ HashAggregate
+   Hash Key: phv_part.a
+   Hash Key: phv_part.b
+   ->  Seq Scan on phv_part_1 phv_part
+         Filter: (a = ANY ('{1,NULL}'::integer[]))
+(5 rows)
+
+select * from (select a, b from phv_part) t
+  where a in (1, null)
+  group by grouping sets (a, b);
+ a |  b  
+---+-----
+ 1 | 
+   | one
+(2 rows)
+
+-- NullTest: IS NULL with PHV-wrapped operand
+explain (costs off)
+select * from (select a, b from phv_part) t
+  where a is null
+  group by grouping sets (a, b);
+                QUERY PLAN                
+------------------------------------------
+ HashAggregate
+   Hash Key: phv_part.a
+   Hash Key: phv_part.b
+   ->  Seq Scan on phv_part_null phv_part
+         Filter: (a IS NULL)
+(5 rows)
+
+select * from (select a, b from phv_part) t
+  where a is null
+  group by grouping sets (a, b);
+ a |  b   
+---+------
+   | 
+   | null
+(2 rows)
+
+drop table phv_part;
+-- BooleanTest: IS TRUE with PHV-wrapped boolean partition key
+create table phv_boolpart (a bool, b text) partition by list (a);
+create table phv_boolpart_t partition of phv_boolpart for values in (true);
+create table phv_boolpart_f partition of phv_boolpart for values in (false);
+create table phv_boolpart_null partition of phv_boolpart default;
+insert into phv_boolpart values (true, 'yes'), (false, 'no'), (null, 'unknown');
+explain (costs off)
+select * from (select a, b from phv_boolpart) t
+  where a is true
+  group by grouping sets (a, b);
+                  QUERY PLAN                   
+-----------------------------------------------
+ HashAggregate
+   Hash Key: phv_boolpart.a
+   Hash Key: phv_boolpart.b
+   ->  Seq Scan on phv_boolpart_t phv_boolpart
+         Filter: (a IS TRUE)
+(5 rows)
+
+select * from (select a, b from phv_boolpart) t
+  where a is true
+  group by grouping sets (a, b);
+ a |  b  
+---+-----
+ t | 
+   | yes
+(2 rows)
+
+drop table phv_boolpart;
diff --git a/src/test/regress/sql/partition_prune.sql b/src/test/regress/sql/partition_prune.sql
index d93c0c03bab..359a9208056 100644
--- a/src/test/regress/sql/partition_prune.sql
+++ b/src/test/regress/sql/partition_prune.sql
@@ -1447,3 +1447,74 @@ select min(a) over (partition by a order by a) from part_abc where a >= stable_o
 
 drop view part_abc_view;
 drop table part_abc;
+
+--
+-- Check that operands wrapped in PlaceHolderVars are matched to partition
+-- keys, allowing partition pruning to occur.  PlaceHolderVars can be
+-- introduced when a subquery's output is used with grouping sets.
+--
+create table phv_part (a int, b text) partition by list (a);
+create table phv_part_1 partition of phv_part for values in (1);
+create table phv_part_2 partition of phv_part for values in (2);
+create table phv_part_null partition of phv_part for values in (null);
+insert into phv_part values (1, 'one'), (2, 'two'), (null, 'null');
+
+-- OpExpr: PHV-wrapped operand matched via equal()
+explain (costs off)
+select * from (select a, b from phv_part) t
+  where a = 1
+  group by grouping sets (a, b);
+
+select * from (select a, b from phv_part) t
+  where a = 1
+  group by grouping sets (a, b);
+
+-- OpExpr with RelabelType: PHV wrapped around a casted column
+explain (costs off)
+select * from (select a::oid as x, b from phv_part) t
+  where x::int = 1
+  group by grouping sets (x, b);
+
+select * from (select a::oid as x, b from phv_part) t
+  where x::int = 1
+  group by grouping sets (x, b);
+
+-- ScalarArrayOpExpr: IN clause with PHV-wrapped operand
+explain (costs off)
+select * from (select a, b from phv_part) t
+  where a in (1, null)
+  group by grouping sets (a, b);
+
+select * from (select a, b from phv_part) t
+  where a in (1, null)
+  group by grouping sets (a, b);
+
+-- NullTest: IS NULL with PHV-wrapped operand
+explain (costs off)
+select * from (select a, b from phv_part) t
+  where a is null
+  group by grouping sets (a, b);
+
+select * from (select a, b from phv_part) t
+  where a is null
+  group by grouping sets (a, b);
+
+drop table phv_part;
+
+-- BooleanTest: IS TRUE with PHV-wrapped boolean partition key
+create table phv_boolpart (a bool, b text) partition by list (a);
+create table phv_boolpart_t partition of phv_boolpart for values in (true);
+create table phv_boolpart_f partition of phv_boolpart for values in (false);
+create table phv_boolpart_null partition of phv_boolpart default;
+insert into phv_boolpart values (true, 'yes'), (false, 'no'), (null, 'unknown');
+
+explain (costs off)
+select * from (select a, b from phv_boolpart) t
+  where a is true
+  group by grouping sets (a, b);
+
+select * from (select a, b from phv_boolpart) t
+  where a is true
+  group by grouping sets (a, b);
+
+drop table phv_boolpart;
-- 
2.39.5 (Apple Git-154)

