From 5a3ccaeb803b72a043e6eabf824a824021da3917 Mon Sep 17 00:00:00 2001
From: Amit Langote <amitlan@postgresql.org>
Date: Fri, 7 Nov 2025 14:48:10 +0900
Subject: [PATCH v2] Fix bogus ctid requirement for dummy-root partitioned
 targets

ExecInitModifyTable() unconditionally required a ctid junk column even
when the target was a partitioned table. This led to spurious "could
not find junk ctid column" errors when all children were excluded and
only the dummy root result relation remained.

Require ctid for heap relations as before. For partitioned tables,
require it only when at least one leaf result relation remains in the
plan (nrels > 1). If the plan has only the dummy root, no rows can be
produced and ctid is not needed.
---
 contrib/file_fdw/expected/file_fdw.out | 88 ++++++++++++++++++++++++++
 contrib/file_fdw/sql/file_fdw.sql      | 38 +++++++++++
 src/backend/executor/nodeModifyTable.c | 11 +++-
 3 files changed, 136 insertions(+), 1 deletion(-)

diff --git a/contrib/file_fdw/expected/file_fdw.out b/contrib/file_fdw/expected/file_fdw.out
index 5121e27dce5..1823ca02106 100644
--- a/contrib/file_fdw/expected/file_fdw.out
+++ b/contrib/file_fdw/expected/file_fdw.out
@@ -457,6 +457,94 @@ SELECT tableoid::regclass, * FROM p2;
  p2       | 2 | xyzzy
 (3 rows)
 
+-- Verify that a dummy root partitioned-table result relation works without
+-- error when all child partitions are excluded from the plan (for example,
+-- by constraint exclusion or pruning).  In this case, the executor accepts
+-- a missing ctid for the root result relation since no rows can be produced.
+-- When a foreign-table child is processed before exclusion, a tableoid junk
+-- column may still appear in the targetlist and also wholerow for update.
+-- Dummy-root cases where all children are excluded.
+-- With pruning off, the foreign child is processed first, then excluded
+-- by constraint exclusion. EXPLAIN shows tableoid (rewritten to NULL),
+-- and for UPDATE also wholerow as NULL::record. No ctid.
+DROP TABLE p2;
+SET enable_partition_pruning TO off;
+EXPLAIN VERBOSE DELETE FROM pt WHERE false;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Delete on public.pt  (cost=0.00..0.00 rows=0 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0 width=0)
+         Output: NULL::oid
+         Replaces: Scan on pt
+         One-Time Filter: false
+(5 rows)
+
+-- also cover wholerow for UPDATE; expect NULL::oid and NULL::record
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE false;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Update on public.pt  (cost=0.00..0.00 rows=0 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0 width=68)
+         Output: 'x'::text, NULL::oid, NULL::record
+         Replaces: Scan on pt
+         One-Time Filter: false
+(5 rows)
+
+-- MERGE behaves the same here; expect NULL::oid
+EXPLAIN VERBOSE MERGE INTO pt t USING (VALUES (1, 'x'::text)) AS s(a, b)
+  ON false WHEN MATCHED THEN UPDATE SET b = s.b;
+                       QUERY PLAN                       
+--------------------------------------------------------
+ Merge on public.pt t  (cost=0.00..0.00 rows=0 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0 width=0)
+         Output: NULL::oid
+         Replaces: Scan on t
+         One-Time Filter: false
+(5 rows)
+
+-- With pruning on, the foreign child is pruned entirely. The plan has only
+-- the dummy root, and EXPLAIN shows ctid (and for UPDATE, ctid plus target).
+SET enable_partition_pruning TO on;
+EXPLAIN VERBOSE DELETE FROM pt WHERE false;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Delete on public.pt  (cost=0.00..0.00 rows=0 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0 width=0)
+         Output: ctid
+         Replaces: Scan on pt
+         One-Time Filter: false
+(5 rows)
+
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE false;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Update on public.pt  (cost=0.00..0.00 rows=0 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0 width=38)
+         Output: 'x'::text, ctid
+         Replaces: Scan on pt
+         One-Time Filter: false
+(5 rows)
+
+-- Foreign child not pruned and it does not support DELETE: error.
+EXPLAIN VERBOSE DELETE FROM pt WHERE a = 1;
+ERROR:  cannot delete from foreign table "p1"
+-- UPDATE dummy-root again (with pruning on): still shows ctid.
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE false;
+                      QUERY PLAN                       
+-------------------------------------------------------
+ Update on public.pt  (cost=0.00..0.00 rows=0 width=0)
+   ->  Result  (cost=0.00..0.00 rows=0 width=38)
+         Output: 'x'::text, ctid
+         Replaces: Scan on pt
+         One-Time Filter: false
+(5 rows)
+
+-- Runtime pruning includes the foreign child in the plan; executor errors
+-- since the foreign child does not support the command.
+EXPLAIN VERBOSE DELETE FROM pt WHERE (SELECT false);
+ERROR:  cannot delete from foreign table "p1"
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE (SELECT false);
+ERROR:  cannot update foreign table "p1"
 DROP TABLE pt;
 -- generated column tests
 \set filename :abs_srcdir '/data/list1.csv'
diff --git a/contrib/file_fdw/sql/file_fdw.sql b/contrib/file_fdw/sql/file_fdw.sql
index 1a397ad4bd1..9b249c8b058 100644
--- a/contrib/file_fdw/sql/file_fdw.sql
+++ b/contrib/file_fdw/sql/file_fdw.sql
@@ -242,6 +242,44 @@ UPDATE pt set a = 1 where a = 2; -- ERROR
 SELECT tableoid::regclass, * FROM pt;
 SELECT tableoid::regclass, * FROM p1;
 SELECT tableoid::regclass, * FROM p2;
+
+-- Verify that a dummy root partitioned-table result relation works without
+-- error when all child partitions are excluded from the plan (for example,
+-- by constraint exclusion or pruning).  In this case, the executor accepts
+-- a missing ctid for the root result relation since no rows can be produced.
+-- When a foreign-table child is processed before exclusion, a tableoid junk
+-- column may still appear in the targetlist and also wholerow for update.
+
+-- Dummy-root cases where all children are excluded.
+-- With pruning off, the foreign child is processed first, then excluded
+-- by constraint exclusion. EXPLAIN shows tableoid (rewritten to NULL),
+-- and for UPDATE also wholerow as NULL::record. No ctid.
+DROP TABLE p2;
+SET enable_partition_pruning TO off;
+EXPLAIN VERBOSE DELETE FROM pt WHERE false;
+-- also cover wholerow for UPDATE; expect NULL::oid and NULL::record
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE false;
+-- MERGE behaves the same here; expect NULL::oid
+EXPLAIN VERBOSE MERGE INTO pt t USING (VALUES (1, 'x'::text)) AS s(a, b)
+  ON false WHEN MATCHED THEN UPDATE SET b = s.b;
+
+-- With pruning on, the foreign child is pruned entirely. The plan has only
+-- the dummy root, and EXPLAIN shows ctid (and for UPDATE, ctid plus target).
+SET enable_partition_pruning TO on;
+EXPLAIN VERBOSE DELETE FROM pt WHERE false;
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE false;
+
+-- Foreign child not pruned and it does not support DELETE: error.
+EXPLAIN VERBOSE DELETE FROM pt WHERE a = 1;
+
+-- UPDATE dummy-root again (with pruning on): still shows ctid.
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE false;
+
+-- Runtime pruning includes the foreign child in the plan; executor errors
+-- since the foreign child does not support the command.
+EXPLAIN VERBOSE DELETE FROM pt WHERE (SELECT false);
+EXPLAIN VERBOSE UPDATE pt SET b = 'x' WHERE (SELECT false);
+
 DROP TABLE pt;
 
 -- generated column tests
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 4c5647ac38a..9f4dce8668f 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -4863,7 +4863,16 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 			{
 				resultRelInfo->ri_RowIdAttNo =
 					ExecFindJunkAttributeInTlist(subplan->targetlist, "ctid");
-				if (!AttributeNumberIsValid(resultRelInfo->ri_RowIdAttNo))
+
+				/*
+				 * For heap relations, a ctid junk attribute must be present.
+				 * For partitioned tables, require it only when at least one leaf
+				 * result relation remains in the plan.  If the plan has only the
+				 * dummy root (no leaves), no rows can be produced and ctid is not
+				 * needed.
+				 */
+				if (!AttributeNumberIsValid(resultRelInfo->ri_RowIdAttNo) &&
+					(relkind != RELKIND_PARTITIONED_TABLE || nrels > 1))
 					elog(ERROR, "could not find junk ctid column");
 			}
 			else if (relkind == RELKIND_FOREIGN_TABLE)
-- 
2.47.3

