From 92abeba40fb1ef795a7f0eea198d7c144aef20fe Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Thu, 7 May 2026 11:23:10 +0800
Subject: [PATCH v1] Fix FOR PORTION OF leftovers for inheritance children

ExecForPortionOfLeftovers() assumed that any result relation with
ri_RootResultRelInfo should reinsert temporal leftovers through the root
relation.  That is correct for partitioned tables, where tuple routing is
needed, but it is wrong for plain inheritance.

When UPDATE/DELETE FOR PORTION OF is run on an inheritance parent and a
child row is split, the leftover rows must be inserted back into the child
relation.  Reinserting through the parent can lose child-only columns and
place the leftover rows in the wrong relation.

Fix this by distinguishing partitioned-table routing from plain
inheritance.  For partitioned tables, keep using the root leftover slot and
insert through the root relation.  For plain inheritance children, use a
leftover slot matching the child relation and insert directly into the
child.  Also keep translating the application-time column attno for child
relations, so multiple-inheritance cases with different attribute numbers
are handled correctly.

Add regression tests for UPDATE and DELETE FOR PORTION OF on inheritance
children, including a multiple-inheritance case where the range column has
a different attnum in the parent and child.

Author: Chao Li <lic@highgo.com>
Reviewed-by:
Discussion: https://postgr.es/m/
---
 src/backend/executor/nodeModifyTable.c       |  76 ++++++++----
 src/test/regress/expected/for_portion_of.out | 119 +++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      |  80 +++++++++++++
 3 files changed, 252 insertions(+), 23 deletions(-)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 4cb057ca4f9..df3e3bb1b98 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -1409,7 +1409,11 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ModifyTableState *mtstate = context->mtstate;
 	ModifyTable *node = (ModifyTable *) mtstate->ps.plan;
 	ForPortionOfExpr *forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
-	AttrNumber	rangeAttno;
+	AttrNumber	actualRangeAttno;	/* attno of range column in the current
+									 * result relation */
+	AttrNumber	targetRangeAttno;	/* attno of range column in the target
+									 * table of the query */
+	AttrNumber	leftoverRangeAttno;
 	Datum		oldRange;
 	TypeCacheEntry *typcache;
 	ForPortionOfState *fpoState;
@@ -1422,18 +1426,30 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	FmgrInfo	flinfo;
 	PgStat_FunctionCallUsage fcusage;
 	ReturnSetInfo rsi;
+	ResultRelInfo *rootRri = resultRelInfo->ri_RootResultRelInfo;
 	bool		didInit = false;
 	bool		shouldFree = false;
+	bool		partitionRouting;
 
 	LOCAL_FCINFO(fcinfo, 2);
 
+	partitionRouting =
+		rootRri != NULL &&
+		rootRri->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE;
+	targetRangeAttno = forPortionOf->rangeVar->varattno;
+
 	if (!resultRelInfo->ri_forPortionOf)
 	{
 		/*
-		 * If we don't have a ForPortionOfState yet, we must be a partition
-		 * child being hit for the first time. Make a copy from the root, with
-		 * our own TupleTableSlot. We do this lazily so that we don't pay the
-		 * price of unused partitions.
+		 * If we don't have a ForPortionOfState yet, we must be a child
+		 * relation being hit for the first time. Make a copy from the root,
+		 * with slots matching the child's tuple descriptor.
+		 *
+		 * Partitions share the root leftover slot, since they must insert via
+		 * the root relation to get tuple routing. Plain inheritance children
+		 * must keep their own leftover slot and insert back into the child,
+		 * or else child-only column values and physical placement would be
+		 * lost.
 		 */
 		ForPortionOfState *leafState = makeNode(ForPortionOfState);
 
@@ -1447,8 +1463,15 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		leafState->fp_rangeType = fpoState->fp_rangeType;
 		leafState->fp_rangeAttno = fpoState->fp_rangeAttno;
 		leafState->fp_targetRange = fpoState->fp_targetRange;
-		leafState->fp_Leftover = fpoState->fp_Leftover;
-		/* Each partition needs a slot matching its tuple descriptor */
+		if (partitionRouting)
+			leafState->fp_Leftover = fpoState->fp_Leftover;
+		else
+			leafState->fp_Leftover =
+				ExecInitExtraTupleSlot(mtstate->ps.state,
+									   resultRelInfo->ri_RelationDesc->rd_att,
+									   &TTSOpsVirtual);
+
+		/* Each child relation needs a slot matching its tuple descriptor */
 		leafState->fp_Existing =
 			table_slot_create(resultRelInfo->ri_RelationDesc,
 							  &mtstate->ps.state->es_tupleTable);
@@ -1476,20 +1499,22 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		elog(ERROR, "failed to fetch tuple for FOR PORTION OF");
 
 	/*
-	 * Get the old range of the record being updated/deleted. Must read with
-	 * the attno of the leaf partition being updated.
+	 * Get the old range of the record being updated/deleted. The query
+	 * expression uses the target relation's attno, but the tuple slot may
+	 * belong to a child relation.  If so, translate it to the corresponding
+	 * child attno.
 	 */
 
-	rangeAttno = forPortionOf->rangeVar->varattno;
-	if (resultRelInfo->ri_RootResultRelInfo)
+	actualRangeAttno = targetRangeAttno;
+	if (rootRri != NULL)
 		map = ExecGetChildToRootMap(resultRelInfo);
 	if (map != NULL)
-		rangeAttno = map->attrMap->attnums[rangeAttno - 1];
+		actualRangeAttno = map->attrMap->attnums[actualRangeAttno - 1];
 	slot_getallattrs(oldtupleSlot);
 
-	if (oldtupleSlot->tts_isnull[rangeAttno - 1])
+	if (oldtupleSlot->tts_isnull[actualRangeAttno - 1])
 		elog(ERROR, "found a NULL range in a temporal table");
-	oldRange = oldtupleSlot->tts_values[rangeAttno - 1];
+	oldRange = oldtupleSlot->tts_values[actualRangeAttno - 1];
 
 	/*
 	 * Get the range's type cache entry. This is worth caching for the whole
@@ -1529,10 +1554,15 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	/*
 	 * If there are partitions, we must insert into the root table, so we get
 	 * tuple routing. We already set up leftoverSlot with the root tuple
-	 * descriptor.
+	 * descriptor for that case.
 	 */
-	if (resultRelInfo->ri_RootResultRelInfo)
-		resultRelInfo = resultRelInfo->ri_RootResultRelInfo;
+	if (partitionRouting)
+	{
+		resultRelInfo = rootRri;
+		leftoverRangeAttno = targetRangeAttno;
+	}
+	else
+		leftoverRangeAttno = actualRangeAttno;
 
 	/*
 	 * Insert a leftover for each value returned by the without_portion helper
@@ -1574,11 +1604,11 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		{
 			/*
 			 * Make a copy of the pre-UPDATE row. Then we'll overwrite the
-			 * range column below. Convert oldtuple to the base table's format
-			 * if necessary. We need to insert temporal leftovers through the
-			 * root partition so they get routed correctly.
+			 * range column below. Only partitioned targets need conversion to
+			 * the root table's format, because they reinsert through the root
+			 * relation for tuple routing.
 			 */
-			if (map != NULL)
+			if (partitionRouting && map != NULL)
 			{
 				leftoverSlot = execute_attr_map_slot(map->attrMap,
 													 oldtupleSlot,
@@ -1601,8 +1631,8 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 			didInit = true;
 		}
 
-		leftoverSlot->tts_values[forPortionOf->rangeVar->varattno - 1] = leftover;
-		leftoverSlot->tts_isnull[forPortionOf->rangeVar->varattno - 1] = false;
+		leftoverSlot->tts_values[leftoverRangeAttno - 1] = leftover;
+		leftoverSlot->tts_isnull[leftoverRangeAttno - 1] = false;
 		ExecMaterializeSlot(leftoverSlot);
 
 		/*
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 0c0a205c44b..7d7847c9ad7 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -2097,6 +2097,125 @@ SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
 (4 rows)
 
 DROP TABLE temporal_partitioned;
+-- UPDATE/DELETE FOR PORTION OF with inheritance
+CREATE TABLE fpo_parent (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_child (
+  extra text
+) INHERITS (fpo_parent);
+INSERT INTO fpo_child VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'old', 'x');
+UPDATE fpo_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'new'
+  WHERE id = 1;
+SELECT * FROM ONLY fpo_parent;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+SELECT * FROM ONLY fpo_child;
+ id |        valid_at         | name | extra 
+----+-------------------------+------+-------
+  1 | [2001-01-01,2002-01-01) | new  | x
+  1 | [2000-01-01,2001-01-01) | old  | x
+  1 | [2002-01-01,2010-01-01) | old  | x
+(3 rows)
+
+TRUNCATE TABLE fpo_child, fpo_parent;
+INSERT INTO fpo_child VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'old', 'x');
+DELETE FROM fpo_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  WHERE id = 1;
+SELECT * FROM ONLY fpo_parent;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+SELECT * FROM ONLY fpo_child;
+ id |        valid_at         | name | extra 
+----+-------------------------+------+-------
+  1 | [2000-01-01,2001-01-01) | old  | x
+  1 | [2002-01-01,2010-01-01) | old  | x
+(2 rows)
+
+DROP TABLE fpo_child, fpo_parent;
+-- UPDATE/DELETE FOR PORTION OF with inheritance and differing attnos
+CREATE TABLE temporal_parent (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE other_parent (
+  prefix text,
+  note text
+);
+CREATE TABLE mi_child () INHERITS (other_parent, temporal_parent);
+-- attnum of the range column is different in temporal_parent and mi_child
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'temporal_parent'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+ attnum | attname  
+--------+----------
+      1 | id
+      2 | valid_at
+      3 | name
+(3 rows)
+
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'mi_child'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+ attnum | attname  
+--------+----------
+      1 | prefix
+      2 | note
+      3 | id
+      4 | valid_at
+      5 | name
+(5 rows)
+
+INSERT INTO mi_child VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+UPDATE temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'new'
+  WHERE id = 1;
+SELECT * FROM ONLY temporal_parent;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+SELECT * FROM ONLY mi_child;
+ prefix | note | id |        valid_at         | name 
+--------+------+----+-------------------------+------
+ pfx    | memo |  1 | [2001-01-01,2002-01-01) | new
+ pfx    | memo |  1 | [2000-01-01,2001-01-01) | old
+ pfx    | memo |  1 | [2002-01-01,2010-01-01) | old
+(3 rows)
+
+TRUNCATE TABLE mi_child, other_parent, temporal_parent;
+INSERT INTO mi_child VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+DELETE FROM temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  WHERE id = 1;
+SELECT * FROM ONLY temporal_parent;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+SELECT * FROM ONLY mi_child;
+ prefix | note | id |        valid_at         | name 
+--------+------+----+-------------------------+------
+ pfx    | memo |  1 | [2000-01-01,2001-01-01) | old
+ pfx    | memo |  1 | [2002-01-01,2010-01-01) | old
+(2 rows)
+
+DROP TABLE mi_child, other_parent, temporal_parent;
 -- UPDATE/DELETE FOR PORTION OF with RULEs
 CREATE TABLE fpo_rule (f1 bigint, f2 int4range);
 INSERT INTO fpo_rule VALUES (1, '[1, 11)');
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index fd79a9b78e7..83a2bc9a82c 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1365,6 +1365,86 @@ SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
 
 DROP TABLE temporal_partitioned;
 
+-- UPDATE/DELETE FOR PORTION OF with inheritance
+CREATE TABLE fpo_parent (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_child (
+  extra text
+) INHERITS (fpo_parent);
+
+INSERT INTO fpo_child VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'old', 'x');
+
+UPDATE fpo_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'new'
+  WHERE id = 1;
+
+SELECT * FROM ONLY fpo_parent;
+SELECT * FROM ONLY fpo_child;
+
+TRUNCATE TABLE fpo_child, fpo_parent;
+
+INSERT INTO fpo_child VALUES
+  (1, daterange('2000-01-01', '2010-01-01'), 'old', 'x');
+
+DELETE FROM fpo_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  WHERE id = 1;
+
+SELECT * FROM ONLY fpo_parent;
+SELECT * FROM ONLY fpo_child;
+
+DROP TABLE fpo_child, fpo_parent;
+
+-- UPDATE/DELETE FOR PORTION OF with inheritance and differing attnos
+CREATE TABLE temporal_parent (
+  id int,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE other_parent (
+  prefix text,
+  note text
+);
+CREATE TABLE mi_child () INHERITS (other_parent, temporal_parent);
+
+-- attnum of the range column is different in temporal_parent and mi_child
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'temporal_parent'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+SELECT attnum, attname
+  FROM pg_attribute
+  WHERE attrelid = 'mi_child'::regclass
+    AND attnum > 0 AND NOT attisdropped
+  ORDER BY attnum;
+
+INSERT INTO mi_child VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+
+UPDATE temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  SET name = 'new'
+  WHERE id = 1;
+
+SELECT * FROM ONLY temporal_parent;
+SELECT * FROM ONLY mi_child;
+
+TRUNCATE TABLE mi_child, other_parent, temporal_parent;
+
+INSERT INTO mi_child VALUES
+  ('pfx', 'memo', 1, daterange('2000-01-01', '2010-01-01'), 'old');
+
+DELETE FROM temporal_parent FOR PORTION OF valid_at FROM '2001-01-01' TO '2002-01-01'
+  WHERE id = 1;
+
+SELECT * FROM ONLY temporal_parent;
+SELECT * FROM ONLY mi_child;
+
+DROP TABLE mi_child, other_parent, temporal_parent;
+
 -- UPDATE/DELETE FOR PORTION OF with RULEs
 CREATE TABLE fpo_rule (f1 bigint, f2 int4range);
 INSERT INTO fpo_rule VALUES (1, '[1, 11)');
-- 
2.50.1 (Apple Git-155)

