From e639fc0d40572b5c72afe817dbe66e37852a66b5 Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Thu, 7 May 2026 13:32:05 +0800
Subject: [PATCH v10 2/2] Fix FOR PORTION OF on inherited children with
 differing attnos

---
 src/backend/executor/nodeModifyTable.c       | 36 ++++----
 src/test/regress/expected/for_portion_of.out | 88 ++++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 48 +++++++++++
 3 files changed, 157 insertions(+), 15 deletions(-)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 81f5afc9fb7..f6bf4ebbb11 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -1493,9 +1493,9 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	fcinfo->args[1].isnull = false;
 
 	/*
-	 * For partitioned tables, we must read leftovers with the tuple descriptor
-	 * of the child table, but insert into the root table to enable tuple
-	 * routing.  So leftoverSlot is configured with the root's tuple
+	 * For partitioned tables, we must read leftovers with the tuple
+	 * descriptor of the child table, but insert into the root table to enable
+	 * tuple routing.  So leftoverSlot is configured with the root's tuple
 	 * descriptor.  However, for traditional table inheritance, we don't need
 	 * tuple routing and just insert directly into the child table to preserve
 	 * child-specific columns.  In that case, leftoverSlot uses the child's
@@ -5861,6 +5861,7 @@ ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, ResultRelInfo *r
 	ForPortionOfState *leafState;
 	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
 	ForPortionOfState *fpoState;
+	TupleConversionMap *map;
 
 	if (!rootRelInfo)
 		elog(ERROR, "no root relation but ri_forPortionOf is uninitialized");
@@ -5875,34 +5876,39 @@ ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, ResultRelInfo *r
 	leafState->fp_rangeName = fpoState->fp_rangeName;
 	leafState->fp_rangeType = fpoState->fp_rangeType;
 	leafState->fp_targetRange = fpoState->fp_targetRange;
+	map = ExecGetChildToRootMap(resultRelInfo);
+
+	/*
+	 * fp_rangeAttno must match the tuple layout used for reading the old
+	 * range value.  The query uses the target relation's attno, so translate
+	 * it to the child attno when the child has a different column layout.
+	 */
+	if (map)
+		leafState->fp_rangeAttno = map->attrMap->attnums[fpoState->fp_rangeAttno - 1];
+	else
+		leafState->fp_rangeAttno = fpoState->fp_rangeAttno;
 
 	/*
-	 * For partitioned tables we must read the leftovers using the child table's
-	 * tuple descriptor, but then insert them into the root table (using its
-	 * tuple descriptor) so we get tuple routing.
+	 * For partitioned tables we must read the leftovers using the child
+	 * table's tuple descriptor, but then insert them into the root table
+	 * (using its tuple descriptor) so we get tuple routing.
 	 *
-	 * For traditional table inheritance, we read and insert directly into this
-	 * resultRelInfo; no tuple routing to the parent is required.
+	 * For traditional table inheritance, we read and insert directly into
+	 * this resultRelInfo; no tuple routing to the parent is required.
 	 */
 	if (rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
 	{
-		TupleConversionMap *map = ExecGetChildToRootMap(resultRelInfo);
-		if (map)
-			leafState->fp_rangeAttno = map->attrMap->attnums[fpoState->fp_rangeAttno - 1];
-		else
-			leafState->fp_rangeAttno = fpoState->fp_rangeAttno;
 		leafState->fp_Leftover = fpoState->fp_Leftover;
 	}
 	else
 	{
-		leafState->fp_rangeAttno = fpoState->fp_rangeAttno;
 		leafState->fp_Leftover =
 			ExecInitExtraTupleSlot(mtstate->ps.state,
 								   RelationGetDescr(resultRelInfo->ri_RelationDesc),
 								   &TTSOpsVirtual);
 	}
 
-	/* Each partition needs a slot matching its tuple descriptor */
+	/* Each child relation needs a slot matching its tuple descriptor */
 	leafState->fp_Existing =
 		table_slot_create(resultRelInfo->ri_RelationDesc,
 						  &mtstate->ps.state->es_tupleTable);
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 91241463991..8c0a35f85e5 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -2279,6 +2279,94 @@ SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
 ----+----------+------
 (0 rows)
 
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+   tableoid    |  id   |        valid_at         | name 
+---------------+-------+-------------------------+------
+ fpo_inh_child | [1,2) | [2018-01-01,2018-04-01) | one
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(2 rows)
+
+SELECT * FROM fpo_inh_child ORDER BY valid_at;
+  id   |        valid_at         | name | description 
+-------+-------------------------+------+-------------
+ [1,2) | [2018-01-01,2018-04-01) | one  | initial
+ [1,2) | [2018-10-01,2019-01-01) | one  | initial
+(2 rows)
+
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
 DROP TABLE fpo_inh_parent CASCADE;
 NOTICE:  drop cascades to table fpo_inh_child
+-- UPDATE FOR PORTION OF with multiple inheritance
+-- Leftover rows must stay in the child table, even if the range column's
+-- attnum differs between the target parent and child.
+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);
+INSERT INTO mi_child (prefix, note, id, valid_at, name) 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 tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+ tableoid | id |        valid_at         | name 
+----------+----+-------------------------+------
+ mi_child |  1 | [2000-01-01,2001-01-01) | old
+ mi_child |  1 | [2001-01-01,2002-01-01) | new
+ mi_child |  1 | [2002-01-01,2010-01-01) | old
+(3 rows)
+
+SELECT * FROM mi_child ORDER BY valid_at;
+ prefix | note | id |        valid_at         | name 
+--------+------+----+-------------------------+------
+ pfx    | memo |  1 | [2000-01-01,2001-01-01) | old
+ pfx    | memo |  1 | [2001-01-01,2002-01-01) | new
+ pfx    | memo |  1 | [2002-01-01,2010-01-01) | old
+(3 rows)
+
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+TRUNCATE mi_child, other_parent, temporal_parent;
+INSERT INTO mi_child (prefix, note, id, valid_at, name) 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 tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+ tableoid | id |        valid_at         | name 
+----------+----+-------------------------+------
+ mi_child |  1 | [2000-01-01,2001-01-01) | old
+ mi_child |  1 | [2002-01-01,2010-01-01) | old
+(2 rows)
+
+SELECT * FROM mi_child ORDER BY valid_at;
+ 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)
+
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+DROP TABLE temporal_parent CASCADE;
+NOTICE:  drop cascades to table mi_child
 RESET datestyle;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index 04e0dba6375..b1144a13782 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1484,6 +1484,54 @@ SELECT * FROM fpo_inh_child ORDER BY valid_at;
 -- No rows should have leaked into the parent.
 SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
 
+TRUNCATE fpo_inh_child, fpo_inh_parent;
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+
+DELETE FROM fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01';
+
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+SELECT * FROM fpo_inh_child ORDER BY valid_at;
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+
 DROP TABLE fpo_inh_parent CASCADE;
 
+-- UPDATE FOR PORTION OF with multiple inheritance
+-- Leftover rows must stay in the child table, even if the range column's
+-- attnum differs between the target parent and child.
+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);
+
+INSERT INTO mi_child (prefix, note, id, valid_at, name) 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 tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+SELECT * FROM mi_child ORDER BY valid_at;
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+
+TRUNCATE mi_child, other_parent, temporal_parent;
+INSERT INTO mi_child (prefix, note, id, valid_at, name) 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 tableoid::regclass, * FROM temporal_parent ORDER BY valid_at;
+SELECT * FROM mi_child ORDER BY valid_at;
+SELECT * FROM ONLY temporal_parent ORDER BY valid_at;
+
+DROP TABLE temporal_parent CASCADE;
+
 RESET datestyle;
-- 
2.50.1 (Apple Git-155)

