Subject: FOR PORTION OF does not recompute GENERATED STORED columns that depend on the range column

From: SATYANARAYANA NARLAPURAM <[email-masked]>
Date: Tue, 7 Apr 2026 01:42:42 -0700

Hi hackers,

It appears that this is a bug where UPDATE FOR ... PORTION OF fails to
recompute GENERATED ALWAYS AS ... STORED columns whose expressions
reference the range column being narrowed. Please find the repro below.

postgres=# CREATE TABLE t (id int, valid_at int4range NOT NULL, val int,
    range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
STORED);
INSERT INTO t VALUES (1, '[1,100)', 10);
UPDATE t FOR PORTION OF valid_at FROM 30 TO 70 SET val = 99;
SELECT *, upper(valid_at) - lower(valid_at) AS expected FROM t ORDER BY
valid_at;
CREATE TABLE
INSERT 0 1
UPDATE 1
 id | valid_at | val | range_len | expected
----+----------+-----+-----------+----------
  1 | [1,30)   |  10 |        29 |       29
  1 | [30,70)  |  99 |        99 |       40
  1 | [70,100) |  10 |        30 |       30
(3 rows)

The updated row [30,70) retains the stale range_len = 99 from the original
[1,100) range. The leftover inserts are correct because CMD_INSERT
unconditionally recomputes all generated columns. Virtual generated columns
are not affected and are computed correctly because they're evaluated at
read time from the actual stored valid_at value.

Further looking at the code it appears, In transformForPortionOfClause(),
the range column is intentionally not added to perminfo->updatedCols. Since
the range column is absent from updatedCols, any generated stored column
whose expression depends solely on the range column (e.g., upper(valid_at)
- lower(valid_at)) is skipped. Therefore, its expression is never prepared
and never recomputed during the FPO update.

Attached a draft patch that has the test scenario and a fix to address this
issue. In ExecInitGenerated, after retrieving updatedCols, the patch
additionally checks whether the owning ModifyTableState contains an FPO
clause. If it does, the attribute number (attno) of the range column is
added to updatedCols.

Thanks,
Satya

---

From: jian he <[email-masked]>
Date: Fri, 10 Apr 2026 17:10:30 +0800

Hi.

ExecUpdate->ExecUpdateEpilogue->ExecForPortionOfLeftovers
In ExecForPortionOfLeftovers, we have
"""
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.
         */
        ForPortionOfState *leafState = makeNode(ForPortionOfState);
}
"""
We reached the end of ExecUpdate, and then suddenly initialized
resultRelInfo->ri_forPortionOf.
That seems wrong; we should initialize resultRelInfo->ri_forPortionOf
earlier so other places can use that information, such as
ForPortionOfState->fp_rangeAttno.

We can initialize ForPortionOfState right after ExecModifyTable:
"""
/* If it's not the same as last time, we need to locate the rel */
if (resultoid != node->mt_lastResultOid)
    resultRelInfo = ExecLookupResultRelByOid(node, resultoid,
                                                false, true);
"""

In ExecForPortionOfLeftovers, we should use ForPortionOfState more and
ForPortionOfExpr less.
(ForPortionOfExpr and ForPortionOfState share some overlapping information;
maybe we can eliminate some common fields or put ForPortionOfExpr into
ForPortionOfState).

As noted in [1], the FOR PORTION OF column is physically modified,
even though we didn't require explicit UPDATE privileges,
we failed to track this column in ExecGetUpdatedCols and
ExecGetExtraUpdatedCols.
This omission directly impacts the ExecInsertIndexTuples ->
index_unchanged_by_update -> ExecGetExtraUpdatedCols execution path.
We should ensure ExecGetExtraUpdatedCols also accounts for this column.
Otherwise, we need a clearer explanation for why
index_unchanged_by_update can safely ignore a column that is being
physically modified.

I have added regression test cases for CREATE TRIGGER UPDATE OF column_name.

The attached patch also addressed the table inheritance issue in
https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]

I've combined all these changes into a single patch for now, as they
seem closely related.

[1]: https://postgr.es/m/[email-masked]

--
jian
https://www.enterprisedb.com/
From 15c21b82987dd90498cddb92c949343223544e42 Mon Sep 17 00:00:00 2001
From: jian he <[email-masked]>
Date: Fri, 10 Apr 2026 17:01:12 +0800
Subject: [PATCH v2 1/1] FOR PORTION OF UPDATE misc

We reached the end of ExecUpdate, and then suddenly initialized
resultRelInfo->ri_forPortionOf.  That seems wrong; we should initialize
resultRelInfo->ri_forPortionOf earlier so other places can use that information,
such as ForPortionOfState->fp_rangeAttno.

refactor ExecForPortionOfLeftovers, add function ExecInitForPortionOf.
Fix FOR PORTION OF UPDATE modified column and table inheritence issue.

For UPDATE ... FOR PORTION OF, the range column is actually being modified
(narrowed via intersection), but it is not included in updatedCols because the
user does not need UPDATE permission on it. So we need to add it to
ri_extraUpdatedCols.

discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
commitfest entry: https://commitfest.postgresql.org/patch/
---
 src/backend/executor/execUtils.c             |  22 +++
 src/backend/executor/nodeModifyTable.c       | 148 +++++++++++++------
 src/include/nodes/execnodes.h                |   3 +-
 src/test/regress/expected/for_portion_of.out | 123 +++++++++++++++
 src/test/regress/sql/for_portion_of.sql      |  84 +++++++++++
 5 files changed, 336 insertions(+), 44 deletions(-)

diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..5df7f2edf85 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -1430,7 +1430,29 @@ ExecGetExtraUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 {
 	/* Compute the info if we didn't already */
 	if (!relinfo->ri_extraUpdatedCols_valid)
+	{
+		if (relinfo->ri_forPortionOf)
+		{
+			MemoryContext oldContext;
+
+			AttrNumber	rangeAttno = relinfo->ri_forPortionOf->fp_rangeAttno;
+
+			oldContext = MemoryContextSwitchTo(estate->es_query_cxt);
+
+			/*
+			 * For UPDATE ... FOR PORTION OF, the range column is actually
+			 * being modified (narrowed via intersection), but it is not
+			 * included in updatedCols because the user does not need UPDATE
+			 * permission on it. So we need to add it to ri_extraUpdatedCols
+			 */
+			relinfo->ri_extraUpdatedCols =
+				bms_add_member(relinfo->ri_extraUpdatedCols, rangeAttno - FirstLowInvalidHeapAttributeNumber);
+
+			MemoryContextSwitchTo(oldContext);
+		}
+
 		ExecInitGenerated(relinfo, estate, CMD_UPDATE);
+	}
 	return relinfo->ri_extraUpdatedCols;
 }
 
diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index ef2a6bc6e9d..254809d7fdc 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -197,6 +197,8 @@ static TupleTableSlot *ExecMergeNotMatched(ModifyTableContext *context,
 static void ExecSetupTransitionCaptureState(ModifyTableState *mtstate, EState *estate);
 static void fireBSTriggers(ModifyTableState *node);
 static void fireASTriggers(ModifyTableState *node);
+static void ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate,
+								 ResultRelInfo *resultRelInfo);

 /*
@@ -475,6 +477,21 @@ ExecInitGenerated(ResultRelInfo *resultRelInfo,
 	else
 		updatedCols = NULL;
 
+	/*
+	 * For UPDATE ... FOR PORTION OF, the range column is also being modified
+	 * (narrowed via intersection), but it is not included in updatedCols
+	 * because the user does not need UPDATE permission on it.  We must
+	 * account for it here so that generated columns referencing the range
+	 * column are recomputed.
+	 */
+	if (resultRelInfo->ri_forPortionOf)
+	{
+		AttrNumber	rangeAttno = resultRelInfo->ri_forPortionOf->fp_rangeAttno;
+
+		updatedCols = bms_add_member(bms_copy(updatedCols),
+									 rangeAttno - FirstLowInvalidHeapAttributeNumber);
+	}
+
 	/*
 	 * Make sure these data structures are built in the per-query memory
 	 * context so they'll survive throughout the query.
@@ -1408,7 +1425,6 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ModifyTableState *mtstate = context->mtstate;
 	ModifyTable *node = (ModifyTable *) mtstate->ps.plan;
 	ForPortionOfExpr *forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
-	AttrNumber	rangeAttno;
 	Datum		oldRange;
 	TypeCacheEntry *typcache;
 	ForPortionOfState *fpoState;
@@ -1422,37 +1438,10 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ReturnSetInfo rsi;
 	bool		didInit = false;
 	bool		shouldFree = false;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
 
 	LOCAL_FCINFO(fcinfo, 2);
 
-	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.
-		 */
-		ForPortionOfState *leafState = makeNode(ForPortionOfState);
-
-		if (!mtstate->rootResultRelInfo)
-			elog(ERROR, "no root relation but ri_forPortionOf is uninitialized");
-
-		fpoState = mtstate->rootResultRelInfo->ri_forPortionOf;
-		Assert(fpoState);
-
-		leafState->fp_rangeName = fpoState->fp_rangeName;
-		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 */
-		leafState->fp_Existing =
-			table_slot_create(resultRelInfo->ri_RelationDesc,
-							  &mtstate->ps.state->es_tupleTable);
-
-		resultRelInfo->ri_forPortionOf = leafState;
-	}
 	fpoState = resultRelInfo->ri_forPortionOf;
 	oldtupleSlot = fpoState->fp_Existing;
 	leftoverSlot = fpoState->fp_Leftover;
@@ -1475,19 +1464,18 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 
 	/*
 	 * Get the old range of the record being updated/deleted. Must read with
-	 * the attno of the leaf partition being updated.
+	 * the attno of the leaf partition being updated, no need do this for
+	 * table inheritence.
 	 */
-
-	rangeAttno = forPortionOf->rangeVar->varattno;
-	if (resultRelInfo->ri_RootResultRelInfo)
+	if (rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
 		map = ExecGetChildToRootMap(resultRelInfo);
-	if (map != NULL)
-		rangeAttno = map->attrMap->attnums[rangeAttno - 1];
+
 	slot_getallattrs(oldtupleSlot);
 
-	if (oldtupleSlot->tts_isnull[rangeAttno - 1])
+	if (oldtupleSlot->tts_isnull[fpoState->fp_rangeAttno - 1])
 		elog(ERROR, "found a NULL range in a temporal table");
-	oldRange = oldtupleSlot->tts_values[rangeAttno - 1];
+	oldRange = oldtupleSlot->tts_values[fpoState->fp_rangeAttno - 1];
 
 	/*
 	 * Get the range's type cache entry. This is worth caching for the whole
@@ -1524,11 +1512,15 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	fcinfo->args[1].isnull = false;
 
 	/*
-	 * 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.
+	 * For partitioned tables, we insert into the root table to enable tuple
+	 * routing, and leftoverSlot is configured with the root's tuple
+	 * descriptor. However, for traditional table inheritance, no need tuple
+	 * routing and just insert directly into the child table to preserve
+	 * child-specific columns. In that case, leftoverSlot uses the child's
+	 * (resultRelInfo) tuple descriptor.
 	 */
-	if (resultRelInfo->ri_RootResultRelInfo)
+	if (rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
 		resultRelInfo = resultRelInfo->ri_RootResultRelInfo;
 
 	/*
@@ -1585,8 +1577,9 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 			didInit = true;
 		}
 
-		leftoverSlot->tts_values[forPortionOf->rangeVar->varattno - 1] = leftover;
-		leftoverSlot->tts_isnull[forPortionOf->rangeVar->varattno - 1] = false;
+		leftoverSlot->tts_values[resultRelInfo->ri_forPortionOf->fp_rangeAttno - 1] = leftover;
+		leftoverSlot->tts_isnull[resultRelInfo->ri_forPortionOf->fp_rangeAttno - 1] = false;
+
 		ExecMaterializeSlot(leftoverSlot);
 
 		/*
@@ -4761,6 +4754,18 @@ ExecModifyTable(PlanState *pstate)
 														 false, true);
 		}
 
+		/*
+		 * 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 ((((ModifyTable *) context.mtstate->ps.plan)->forPortionOf) &&
+			!resultRelInfo->ri_forPortionOf)
+		{
+			ExecInitForPortionOf(context.mtstate, estate, resultRelInfo);
+		}
+
 		/*
 		 * If resultRelInfo->ri_usesFdwDirectModify is true, all we need to do
 		 * here is compute the RETURNING expressions.
@@ -5844,3 +5849,60 @@ ExecReScanModifyTable(ModifyTableState *node)
 	 */
 	elog(ERROR, "ExecReScanModifyTable is not implemented");
 }
+
+static void
+ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, ResultRelInfo *resultRelInfo)
+{
+	MemoryContext oldcxt;
+	ForPortionOfState *leafState;
+	TupleConversionMap *map = NULL;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
+
+	ForPortionOfState *fpoState = mtstate->rootResultRelInfo->ri_forPortionOf;
+
+	/*
+	 * For traditional table inheritance, we insert directly into this
+	 * resultRelInfo; no tuple routing to the parent is required.
+	 */
+	if (rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+		map = ExecGetChildToRootMap(resultRelInfo);
+
+	/* Things built here have to last for the query duration. */
+	oldcxt = MemoryContextSwitchTo(estate->es_query_cxt);
+
+	leafState = makeNode(ForPortionOfState);
+
+	if (!mtstate->rootResultRelInfo)
+		elog(ERROR, "no root relation but ri_forPortionOf is uninitialized");
+
+	leafState->fp_rangeName = fpoState->fp_rangeName;
+	leafState->fp_rangeType = fpoState->fp_rangeType;
+
+	if (map)
+		leafState->fp_rangeAttno = map->attrMap->attnums[fpoState->fp_rangeAttno - 1];
+	else
+		leafState->fp_rangeAttno = fpoState->fp_rangeAttno;
+
+	leafState->fp_targetRange = fpoState->fp_targetRange;
+
+	if (rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+	{
+		leafState->fp_Leftover =
+			ExecInitExtraTupleSlot(mtstate->ps.state,
+								   RelationGetDescr(resultRelInfo->ri_RelationDesc),
+								   &TTSOpsVirtual);
+	}
+	else
+		leafState->fp_Leftover = fpoState->fp_Leftover;
+
+	/* Each partition needs a slot matching its tuple descriptor */
+	leafState->fp_Existing =
+		table_slot_create(resultRelInfo->ri_RelationDesc,
+						  &mtstate->ps.state->es_tupleTable);
+
+	resultRelInfo->ri_forPortionOf = leafState;
+
+	MemoryContextSwitchTo(oldcxt);
+}
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..53c138310db 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -477,7 +477,8 @@ typedef struct ForPortionOfState
 	NodeTag		type;
 
 	char	   *fp_rangeName;	/* the column named in FOR PORTION OF */
-	Oid			fp_rangeType;	/* the type of the FOR PORTION OF expression */
+	Oid			fp_rangeType;	/* the base type (not domain) of the FOR
+								 * PORTION OF expression */
 	int			fp_rangeAttno;	/* the attno of the range column */
 	Datum		fp_targetRange; /* the range/multirange from FOR PORTION OF */
 	TypeCacheEntry *fp_leftoverstypcache;	/* type cache entry of the range */
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 31f772c723d..4405e88c9cc 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -1365,6 +1365,9 @@ $$;
 CREATE TRIGGER fpo_before_stmt
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_stmt1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
 CREATE TRIGGER fpo_after_insert_stmt
   AFTER INSERT ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
@@ -1378,6 +1381,9 @@ CREATE TRIGGER fpo_after_delete_stmt
 CREATE TRIGGER fpo_before_row
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
 CREATE TRIGGER fpo_after_insert_row
   AFTER INSERT ON for_portion_of_test
   FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
@@ -1394,9 +1400,15 @@ UPDATE for_portion_of_test
 NOTICE:  fpo_before_stmt: BEFORE UPDATE STATEMENT:
 NOTICE:    old: <NULL>
 NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt1: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
 NOTICE:  fpo_before_row: BEFORE UPDATE ROW:
 NOTICE:    old: [2019-01-01,2030-01-01)
 NOTICE:    new: [2021-01-01,2022-01-01)
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [2019-01-01,2030-01-01)
+NOTICE:    new: [2021-01-01,2022-01-01)
 NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
 NOTICE:    old: <NULL>
 NOTICE:    new: <NULL>
@@ -2097,4 +2109,115 @@ SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
 (4 rows)
 
 DROP TABLE temporal_partitioned;
+-- UPDATE FOR PORTION OF with generated stored columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,100) |        90 |         90
+(1 row)
+
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_generated
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_generated
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+-- Triggers fpo_before_row1 and fpo_before_row2 should also be fired.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+NOTICE:  fpo_before_row2: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [10,100)
+NOTICE:    new: [30,70)
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,30)  |        20 |         20
+  2 | [30,70)  |        40 |         40
+  1 | [70,100) |        30 |         30
+(3 rows)
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,100) |          91 |           91
+(1 row)
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,30)  |          21 |           21
+  2 | [30,70)  |          42 |           42
+  1 | [70,100) |          31 |           31
+(3 rows)
+
+DROP TABLE fpo_generated;
+-- UPDATE FOR PORTION OF with table inheritance
+-- Leftover rows must stay in the child table, preserving child-specific columns.
+CREATE TABLE fpo_inh_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_inh_child (
+  description text
+) INHERITS (fpo_inh_parent);
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+-- Update targets the parent; the matching row lives in the child.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+-- All three rows should be in the child, with description preserved.
+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-04-01,2018-10-01) | one^1
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 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-04-01,2018-10-01) | one^1 | initial
+ [1,2) | [2018-10-01,2019-01-01) | one   | initial
+(3 rows)
+
+-- No rows should have leaked into the parent.
+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
 RESET datestyle;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index d4062acf1d1..95efa640389 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -913,6 +913,10 @@ CREATE TRIGGER fpo_before_stmt
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
 
+CREATE TRIGGER fpo_before_stmt1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
 CREATE TRIGGER fpo_after_insert_stmt
   AFTER INSERT ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
@@ -931,6 +935,10 @@ CREATE TRIGGER fpo_before_row
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
 
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
 CREATE TRIGGER fpo_after_insert_row
   AFTER INSERT ON for_portion_of_test
   FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
@@ -1365,4 +1373,80 @@ SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
 
 DROP TABLE temporal_partitioned;
 
+-- UPDATE FOR PORTION OF with generated stored columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_generated
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_generated
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+-- Triggers fpo_before_row1 and fpo_before_row2 should also be fired.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+DROP TABLE fpo_generated;
+
+
+-- UPDATE FOR PORTION OF with table inheritance
+-- Leftover rows must stay in the child table, preserving child-specific columns.
+CREATE TABLE fpo_inh_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_inh_child (
+  description text
+) INHERITS (fpo_inh_parent);
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+
+-- Update targets the parent; the matching row lives in the child.
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+
+-- All three rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+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;
+
+DROP TABLE fpo_inh_parent CASCADE;
+
 RESET datestyle;
-- 
2.34.1

---

From: SATYANARAYANA NARLAPURAM <[email-masked]>
Date: Fri, 10 Apr 2026 15:00:57 -0700

Hi Jian,

On Fri, Apr 10, 2026 at 2:11 AM jian he <[email-masked]> wrote:

> [1]:
> https://postgr.es/m/[email-masked]

I applied your patch and tested. The following scenarios are now passing:
(1) table inheritance issue I reported in [1], (2) issue reported in this
thread.

Following are still failing:

(1) instead of triggers + views, mentioned in the thread [2], it has both
the test case and the fix.

(2) For Portion Of DELETE loses rows when a BEFORE INSERT trigger returns
NULL

DROP TABLE IF EXISTS subscriptions CASCADE;
CREATE TABLE subscriptions (
    sub_id   int,
    period   int4range NOT NULL,
    plan     text
);

CREATE OR REPLACE FUNCTION reject_new_subscriptions() RETURNS trigger AS $$
BEGIN
    -- Business rule: no new subscription rows allowed via INSERT.
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER no_new_subs
    BEFORE INSERT ON subscriptions
    FOR EACH ROW EXECUTE FUNCTION reject_new_subscriptions();

-- Pre-existing row (bypass trigger to seed it).
ALTER TABLE subscriptions DISABLE TRIGGER no_new_subs;
INSERT INTO subscriptions VALUES (1, '[1,100)', 'premium');
ALTER TABLE subscriptions ENABLE TRIGGER no_new_subs;

SELECT * FROM subscriptions;
-- 1 row: (1, [1,100), premium)

-- Delete just the [40,60) slice.
DELETE FROM subscriptions FOR PORTION OF period FROM 40 TO 60;

SELECT * FROM subscriptions ORDER BY period;
-- Should be two rows: [1,40) and [60,100)
-- Actually: 0 rows.  The whole subscription vanished.

SELECT count(*) AS remaining FROM subscriptions;
-- Expected 2, got 0.

(3) FPO UPDATE loses leftovers the same way

DROP TABLE IF EXISTS room_bookings CASCADE;
CREATE TABLE room_bookings (
    booking_id int,
    slot       int4range NOT NULL,
    note       text
);

CREATE OR REPLACE FUNCTION block_booking_inserts() RETURNS trigger AS $$
BEGIN
    -- Business rule: bookings created only through an API layer.
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER booking_guard
    BEFORE INSERT ON room_bookings
    FOR EACH ROW EXECUTE FUNCTION block_booking_inserts();

ALTER TABLE room_bookings DISABLE TRIGGER booking_guard;
INSERT INTO room_bookings VALUES (1, '[1,100)', 'team meeting');
ALTER TABLE room_bookings ENABLE TRIGGER booking_guard;

SELECT * FROM room_bookings;
-- 1 row: (1, [1,100), team meeting)

-- Shorten the meeting to only [40,60).
UPDATE room_bookings FOR PORTION OF slot FROM 40 TO 60 SET note =
'shortened';

SELECT * FROM room_bookings ORDER BY slot;
-- Should be three rows:
--   [1,40)   team meeting
--   [40,60)  shortened
--   [60,100) team meeting
-- Actually: only the [40,60) row survives.

SELECT count(*) AS remaining FROM room_bookings;
-- Expected 3, got 1.

[1]:
https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
[2]:
https://www.postgresql.org/message-id/flat/CACJufxHALFKca5SMn5DNnbrX2trPamVL6napn_nm35p15yw%2Brg%40mail.gmail.com#ab4216dc6828fb0d7ada48aab9e330d0

Thanks,
Satya

---

From: jian he <[email-masked]>
Date: Sat, 11 Apr 2026 16:36:51 +0800

On Sat, Apr 11, 2026 at 6:01 AM SATYANARAYANA NARLAPURAM
<[email-masked]> wrote:

> (1) instead of triggers + views, mentioned in the thread [2], it has both the test case and the fix.
>
I will check and reply in that thread.

>
> (2) For Portion Of DELETE loses rows when a BEFORE INSERT trigger returns NULL
>

I think this is expected.
https://www.postgresql.org/docs/devel/sql-delete.html says
<<>>
When FOR PORTION OF is used, this can result in users who don't have INSERT
privileges firing INSERT triggers. This should be considered when using SECURITY
DEFINER trigger functions.
<<>>

We first tried inserting [1,40) and [60,100), but they were rejected
and not inserted
because the trigger function reject_new_subscriptions returned NULL.

See ExecInsert:
``````
    if (resultRelInfo->ri_TrigDesc &&
        resultRelInfo->ri_TrigDesc->trig_insert_before_row)
    {
        /* Flush any pending inserts, so rows are visible to the triggers */
        if (estate->es_insert_pending_result_relations != NIL)
            ExecPendingInserts(estate);
        if (!ExecBRInsertTriggers(estate, resultRelInfo, slot))
            return NULL;        /* "do nothing" */
    }
``````

> UPDATE room_bookings FOR PORTION OF slot FROM 40 TO 60 SET note = 'shortened';
>

For the same reason as above, I think the current behavior is correct.

---

From: Paul A Jungwirth <[email-masked]>
Date: Wed, 15 Apr 2026 13:59:21 -0700

On Fri, Apr 10, 2026 at 3:01 PM SATYANARAYANA NARLAPURAM
<[email-masked]> wrote:
> I applied your patch and tested. The following scenarios are now passing:  (1) table inheritance issue I reported in [1], (2) issue reported in this thread.

They look good to me too. I read through the patch and made some
stylistic changes, as well as some grammar/typo fixes. In
ExecInitForPortionOf I tried to group things for each case more
closely, instead of separate disconnected branches. I also added/moved
some comments. Please see the attached.

There's something I think we could still improve: we omit the
valid-time in updatedCols, since that bitmapset is for permissions
checking (at least originally), but now other features are using it as
well. Our fix adds special logic to consider the valid-at column for
GENERATED column dependencies and more special logic for UPDATE OF
triggers. Perhaps we should add the attno to updatedCols after all,
and put the special logic in the permissions check instead? That seems
simpler and more robust. Or maybe it's time to have separate
bitmapsets, one for permissions and another for everything else? What
do you think?

I'm not sure we should be consolidating these fixes into one patch.
But I tried to call out all the issues in my commit message.

> (1) instead of triggers + views, mentioned in the thread [2], it has both the test case and the fix.

I'll follow up there.

> ...

I agree with jian that this is the correct behavior. The inserts are
supposed to fire triggers, and if a trigger signals to cancel the
insert, that's what we should do.

Yours,

-- 
Paul              ~{:-)
[email-masked]

---

From: jian he <[email-masked]>
Date: Fri, 17 Apr 2026 16:13:28 +0800

+ /*
Putting the above into ExecGetUpdatedCols would be more neat.
InitPlan->ExecCheckPermissions happened earlier than ExecGetUpdatedCols,
So I think it will work.

Another reason to do it this way is that some places only call
ExecGetUpdatedCols, not ExecGetExtraUpdatedCols.
Put the above into ExecGetUpdatedCols, then we don't need to worry
about whether ExecGetExtraUpdatedCols is called.

ExecGetExtraUpdatedCols saves the ri_extraUpdatedCols to estate->es_query_cxt.
For ExecGetUpdatedCols, we can do the same: save the FOR PORTION OF
column to estate->es_query_cxt.
Please find the attached diff, which is based on your V3 patch.

ExecForPortionOfLeftovers
    /*
     * Get the range's type cache entry. This is worth caching for the whole
     * UPDATE/DELETE as range functions do.
     */
    typcache = fpoState->fp_leftoverstypcache;
    if (typcache == NULL)
    {
        typcache = lookup_type_cache(forPortionOf->rangeType, 0);
        fpoState->fp_leftoverstypcache = typcache;
    }
It seems fp_leftoverstypcache is never being used?
place it to ExecInitModifyTable would be better, i think.

    /*
     * Get the ranges to the left/right of the targeted range. We call a SETOF
     * support function and insert as many temporal leftovers as it gives us.
     * Although rangetypes have 0/1/2 leftovers, multiranges have 0/1, and
     * other types may have more.
     */
Currently, we only support anymultirange and anyrange. so here
"and other types may have more." is kind of wrong?

---

From: Paul A Jungwirth <[email-masked]>
Date: Sun, 19 Apr 2026 13:10:06 -0700

> InitPlan->ExecCheckPermissions happened earlier than ExecGetUpdatedCols,

Okay, I like this approach. Thank you for the patch! I'm not sure
about mutating perminfo though. It saves repeated bms allocations for
multi-row updates, but it seems a bit surprising.

> Please find the attached diff, which is based on your V3 patch.

This fix doesn't work for partitioned tables, because in
ExecGetUpdatedCols ri_forPortionOf->fp_rangeAttno has already been
mapped to the child table, but then we add it to the unmapped
bitmapset and map it again. I updated an existing FOR PORTION OF test
on partitioned tables to show this, by adding a GENERATED STORED
column. So instead you need to add to the bitmapset *after* mapping.
That's the approach I've taken here. And then mutating perminfo is
definitely wrong, because it has the parent attnos.

> place it to ExecInitModifyTable would be better, i think.

You're right; that must have been left over from an earlier patch. But
actually the fix about preventing BEFORE triggers from changing
valid_at winds up using it. So I left it in for now.

> "and other types may have more." is kind of wrong?

That's all we support for now, but my goal is to allow user-defined
functions to support other types. So this comment is looking forward
to that future.

v5 attached.

Yours,

-- 
Paul              ~{:-)
[email-masked]
From 1183080005f7e6b165c0b180345228b5b4e0c38b Mon Sep 17 00:00:00 2001
From: jian he <[email-masked]>
Date: Fri, 10 Apr 2026 17:01:12 +0800
Subject: [PATCH v5] Fix some problems with UPDATE FOR PORTION OF

- Fixed inserting leftovers with traditional table inheritance. Since there is
  no tuple routing, we must add them directly to the child table. Also this
  preserves extra columns in that table.
- Added ExecInitForPortionOf. This sets up executor state for child partitions.
  Previously we did this in ExecForPortionOfLeftovers, but doing it earlier lets
  us use the child->parent attr mapping in the fixes below.
- Made sure GENERATED STORED columns that depend on the application-time column
  get updated. We exclude that column from the updatedCols bitmapset, because it
  does not require permissions. But then we must remember to add it later. This
  also fixes a similar problem with UPDATE OF triggers.
- Clarified a comment about the rangetype stored in ForPortionOfState.

Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
Discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
---
 src/backend/executor/execUtils.c             |  35 ++-
 src/backend/executor/nodeModifyTable.c       | 145 ++++++++----
 src/include/nodes/execnodes.h                |   3 +-
 src/test/regress/expected/for_portion_of.out | 219 +++++++++++++++----
 src/test/regress/sql/for_portion_of.sql      |  92 +++++++-
 5 files changed, 397 insertions(+), 97 deletions(-)

diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..29a8d3aba74 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -57,6 +57,7 @@
 #include "parser/parse_relation.h"
 #include "partitioning/partdesc.h"
 #include "port/pg_bitutils.h"
+#include "nodes/print.h"
 #include "storage/lmgr.h"
 #include "utils/builtins.h"
 #include "utils/memutils.h"
@@ -1408,6 +1409,7 @@ Bitmapset *
 ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 {
 	RTEPermissionInfo *perminfo = GetResultRTEPermissionInfo(relinfo, estate);
+	Bitmapset *updatedCols = perminfo->updatedCols;
 
 	if (perminfo == NULL)
 		return NULL;
@@ -1418,10 +1420,39 @@ ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 		TupleConversionMap *map = ExecGetRootToChildMap(relinfo, estate);
 
 		if (map)
-			return execute_attr_map_cols(map->attrMap, perminfo->updatedCols);
+			updatedCols = execute_attr_map_cols(map->attrMap, updatedCols);
 	}
 
-	return perminfo->updatedCols;
+	/*
+	 * For UPDATE ... FOR PORTION OF, the range column is being modified
+	 * (narrowed via intersection), but it is not included in updatedCols
+	 * because the user does not need UPDATE permission on it. Now manualy
+	 * add it to updatedCols. Since ri_forPortionOf->fp_rangeAttno is already
+	 * mapped for the child partition, we have to add it after the mapping just
+	 * above. Also that makes it unsafe to mutate perminfo. XXX: Always add the
+	 * unmapped attno instead (before mapping), and mutate perminfo, to avoid
+	 * repeated allocations?
+	 */
+	if (relinfo->ri_forPortionOf)
+	{
+		AttrNumber	rangeAttno = relinfo->ri_forPortionOf->fp_rangeAttno;
+
+		if (!bms_is_member(rangeAttno - FirstLowInvalidHeapAttributeNumber,
+						   updatedCols))
+		{
+			MemoryContext oldContext;
+
+			oldContext = MemoryContextSwitchTo(estate->es_query_cxt);
+
+			updatedCols =
+				bms_add_member(updatedCols,
+							   rangeAttno - FirstLowInvalidHeapAttributeNumber);
+
+			MemoryContextSwitchTo(oldContext);
+		}
+	}
+
+	return updatedCols;
 }
 
 /* Return a bitmap representing generated columns being updated */
index ef2a6bc6e9d..7af0268646b 100644

 /*
@@ -1408,7 +1410,6 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ModifyTableState *mtstate = context->mtstate;
 	ModifyTable *node = (ModifyTable *) mtstate->ps.plan;
 	ForPortionOfExpr *forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
-	AttrNumber	rangeAttno;
 	Datum		oldRange;
 	TypeCacheEntry *typcache;
 	ForPortionOfState *fpoState;
@@ -1422,37 +1423,10 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ReturnSetInfo rsi;
 	bool		didInit = false;
 	bool		shouldFree = false;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
 
 	LOCAL_FCINFO(fcinfo, 2);
 
@@ -1473,21 +1447,13 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	if (!table_tuple_fetch_row_version(resultRelInfo->ri_RelationDesc, tupleid, SnapshotAny, oldtupleSlot))
 		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.
-	 */
-
-	rangeAttno = forPortionOf->rangeVar->varattno;
-	if (resultRelInfo->ri_RootResultRelInfo)
-		map = ExecGetChildToRootMap(resultRelInfo);
-	if (map != NULL)
-		rangeAttno = map->attrMap->attnums[rangeAttno - 1];
 	slot_getallattrs(oldtupleSlot);
 
+	/* Get the old range of the record being updated/deleted. */
 
 	/*
 	 * Get the range's type cache entry. This is worth caching for the whole
@@ -1524,12 +1490,20 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	fcinfo->args[1].isnull = false;
 
 	/*
-	 * 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.
+	 * 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
+	 * (resultRelInfo) tuple descriptor.
 	 */
-	if (resultRelInfo->ri_RootResultRelInfo)
+	if (rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+	{
+		map = ExecGetChildToRootMap(resultRelInfo);
 		resultRelInfo = resultRelInfo->ri_RootResultRelInfo;
+	}
 
 	/*
 	 * Insert a leftover for each value returned by the without_portion helper
@@ -1585,8 +1559,9 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 			didInit = true;
 		}

 		/*
@@ -4761,6 +4736,18 @@ ExecModifyTable(PlanState *pstate)
 														 false, true);
 		}
 
+		/*
+		 * 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 ((((ModifyTable *) context.mtstate->ps.plan)->forPortionOf) &&
+			!resultRelInfo->ri_forPortionOf)
+		{
+			ExecInitForPortionOf(context.mtstate, estate, resultRelInfo);
+		}
+
 		/*
 		 * If resultRelInfo->ri_usesFdwDirectModify is true, all we need to do
 		 * here is compute the RETURNING expressions.
@@ -5844,3 +5831,67 @@ ExecReScanModifyTable(ModifyTableState *node)
 	 */
 	elog(ERROR, "ExecReScanModifyTable is not implemented");
 }
+
+/* ----------------------------------------------------------------
+ *		ExecInitForPortionOf
+ *
+ *		Initializes resultRelInfo->ri_forPortionOf for child tables.
+ * ----------------------------------------------------------------
+ */
+static void
+ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, ResultRelInfo *resultRelInfo)
+{
+	MemoryContext oldcxt;
+	ForPortionOfState *leafState;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
+	ForPortionOfState *fpoState;
+
+	if (!rootRelInfo)
+		elog(ERROR, "no root relation but ri_forPortionOf is uninitialized");
+
+	fpoState = mtstate->rootResultRelInfo->ri_forPortionOf;
+
+	/* Things built here have to last for the query duration. */
+	oldcxt = MemoryContextSwitchTo(estate->es_query_cxt);
+
+	leafState = makeNode(ForPortionOfState);
+
+	leafState->fp_rangeName = fpoState->fp_rangeName;
+	leafState->fp_rangeType = fpoState->fp_rangeType;
+	leafState->fp_targetRange = fpoState->fp_targetRange;
+
+	/*
+	 * 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.
+	 */
+	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 */
+	leafState->fp_Existing =
+		table_slot_create(resultRelInfo->ri_RelationDesc,
+						  &mtstate->ps.state->es_tupleTable);
+
+	resultRelInfo->ri_forPortionOf = leafState;
+
+	MemoryContextSwitchTo(oldcxt);
+}
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..53c138310db 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -477,7 +477,8 @@ typedef struct ForPortionOfState
 	NodeTag		type;
 
 	char	   *fp_rangeName;	/* the column named in FOR PORTION OF */
-	Oid			fp_rangeType;	/* the type of the FOR PORTION OF expression */
+	Oid			fp_rangeType;	/* the base type (not domain) of the FOR
+								 * PORTION OF expression */
 	int			fp_rangeAttno;	/* the attno of the range column */
 	Datum		fp_targetRange; /* the range/multirange from FOR PORTION OF */
 	TypeCacheEntry *fp_leftoverstypcache;	/* type cache entry of the range */
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 31f772c723d..602aca6e6aa 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -1365,6 +1365,9 @@ $$;
 CREATE TRIGGER fpo_before_stmt
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_stmt1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
 CREATE TRIGGER fpo_after_insert_stmt
   AFTER INSERT ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
@@ -1378,6 +1381,9 @@ CREATE TRIGGER fpo_after_delete_stmt
 CREATE TRIGGER fpo_before_row
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON for_portion_of_test
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
 CREATE TRIGGER fpo_after_insert_row
   AFTER INSERT ON for_portion_of_test
   FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
@@ -1394,9 +1400,15 @@ UPDATE for_portion_of_test
 NOTICE:  fpo_before_stmt: BEFORE UPDATE STATEMENT:
 NOTICE:    old: <NULL>
 NOTICE:    new: <NULL>
+NOTICE:  fpo_before_stmt1: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
 NOTICE:  fpo_before_row: BEFORE UPDATE ROW:
 NOTICE:    old: [2019-01-01,2030-01-01)
 NOTICE:    new: [2021-01-01,2022-01-01)
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [2019-01-01,2030-01-01)
+NOTICE:    new: [2021-01-01,2022-01-01)
 NOTICE:  fpo_before_stmt: BEFORE INSERT STATEMENT:
 NOTICE:    old: <NULL>
 NOTICE:    new: <NULL>
@@ -1986,6 +1998,7 @@ SELECT * FROM for_portion_of_test2 ORDER BY id, valid_at;
 DROP TABLE for_portion_of_test2;
 DROP TYPE mydaterange;
 -- Test FOR PORTION OF against a partitioned table.
+-- Include a GENERATED STORED column to test updatedCols column mapping.
 -- temporal_partitioned_1 has the same attnums as the root
 -- temporal_partitioned_3 has the different attnums from the root
 -- temporal_partitioned_5 has the different attnums too, but reversed
@@ -1993,29 +2006,34 @@ CREATE TABLE temporal_partitioned (
   id int4range,
   valid_at daterange,
   name text,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
   CONSTRAINT temporal_paritioned_uq UNIQUE (id, valid_at WITHOUT OVERLAPS)
 ) PARTITION BY LIST (id);
 CREATE TABLE temporal_partitioned_1 PARTITION OF temporal_partitioned FOR VALUES IN ('[1,2)', '[2,3)');
 CREATE TABLE temporal_partitioned_3 PARTITION OF temporal_partitioned FOR VALUES IN ('[3,4)', '[4,5)');
 CREATE TABLE temporal_partitioned_5 PARTITION OF temporal_partitioned FOR VALUES IN ('[5,6)', '[6,7)');
 ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_3;
-ALTER TABLE temporal_partitioned_3 DROP COLUMN id, DROP COLUMN valid_at;
+ALTER TABLE temporal_partitioned_3 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
+NOTICE:  drop cascades to column range_len of table temporal_partitioned_3
 ALTER TABLE temporal_partitioned_3 ADD COLUMN id int4range NOT NULL, ADD COLUMN valid_at daterange NOT NULL;
+ALTER TABLE temporal_partitioned_3 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
 ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_3 FOR VALUES IN ('[3,4)', '[4,5)');
 ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_5;
-ALTER TABLE temporal_partitioned_5 DROP COLUMN id, DROP COLUMN valid_at;
+ALTER TABLE temporal_partitioned_5 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
+NOTICE:  drop cascades to column range_len of table temporal_partitioned_5
 ALTER TABLE temporal_partitioned_5 ADD COLUMN valid_at daterange NOT NULL, ADD COLUMN id int4range NOT NULL;
+ALTER TABLE temporal_partitioned_5 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
 ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_5 FOR VALUES IN ('[5,6)', '[6,7)');
 INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
   ('[1,2)', daterange('2000-01-01', '2010-01-01'), 'one'),
   ('[3,4)', daterange('2000-01-01', '2010-01-01'), 'three'),
   ('[5,6)', daterange('2000-01-01', '2010-01-01'), 'five');
 SELECT * FROM temporal_partitioned;
-  id   |        valid_at         | name  
--------+-------------------------+-------
- [1,2) | [2000-01-01,2010-01-01) | one
- [3,4) | [2000-01-01,2010-01-01) | three
- [5,6) | [2000-01-01,2010-01-01) | five
+  id   |        valid_at         | name  | range_len 
+-------+-------------------------+-------+-----------
+ [1,2) | [2000-01-01,2010-01-01) | one   |      3653
+ [3,4) | [2000-01-01,2010-01-01) | three |      3653
+ [5,6) | [2000-01-01,2010-01-01) | five  |      3653
 (3 rows)
 
 -- Update without moving within partition 1
@@ -2047,54 +2065,165 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-0
   WHERE id = '[5,6)';
 -- Update all partitions at once (each with leftovers)
 SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
-  id   |        valid_at         |  name   
--------+-------------------------+---------
- [1,2) | [2000-01-01,2000-03-01) | one
- [1,2) | [2000-03-01,2000-04-01) | one^1
- [1,2) | [2000-04-01,2000-06-01) | one
- [1,2) | [2000-07-01,2010-01-01) | one
- [2,3) | [2000-06-01,2000-07-01) | three^2
- [3,4) | [2000-01-01,2000-03-01) | three
- [3,4) | [2000-03-01,2000-04-01) | three^1
- [3,4) | [2000-04-01,2000-06-01) | three
- [3,4) | [2000-06-01,2000-07-01) | five^2
- [3,4) | [2000-07-01,2010-01-01) | three
- [4,5) | [2000-06-01,2000-07-01) | one^2
- [5,6) | [2000-01-01,2000-03-01) | five
- [5,6) | [2000-03-01,2000-04-01) | five^1
- [5,6) | [2000-04-01,2000-06-01) | five
- [5,6) | [2000-07-01,2010-01-01) | five
+  id   |        valid_at         |  name   | range_len 
+-------+-------------------------+---------+-----------
+ [1,2) | [2000-01-01,2000-03-01) | one     |        60
+ [1,2) | [2000-03-01,2000-04-01) | one^1   |        31
+ [1,2) | [2000-04-01,2000-06-01) | one     |        61
+ [1,2) | [2000-07-01,2010-01-01) | one     |      3471
+ [2,3) | [2000-06-01,2000-07-01) | three^2 |        30
+ [3,4) | [2000-01-01,2000-03-01) | three   |        60
+ [3,4) | [2000-03-01,2000-04-01) | three^1 |        31
+ [3,4) | [2000-04-01,2000-06-01) | three   |        61
+ [3,4) | [2000-06-01,2000-07-01) | five^2  |        30
+ [3,4) | [2000-07-01,2010-01-01) | three   |      3471
+ [4,5) | [2000-06-01,2000-07-01) | one^2   |        30
+ [5,6) | [2000-01-01,2000-03-01) | five    |        60
+ [5,6) | [2000-03-01,2000-04-01) | five^1  |        31
+ [5,6) | [2000-04-01,2000-06-01) | five    |        61
+ [5,6) | [2000-07-01,2010-01-01) | five    |      3471
 (15 rows)
 
 SELECT * FROM temporal_partitioned_1 ORDER BY id, valid_at;
-  id   |        valid_at         |  name   
--------+-------------------------+---------
- [1,2) | [2000-01-01,2000-03-01) | one
- [1,2) | [2000-03-01,2000-04-01) | one^1
- [1,2) | [2000-04-01,2000-06-01) | one
- [1,2) | [2000-07-01,2010-01-01) | one
- [2,3) | [2000-06-01,2000-07-01) | three^2
+  id   |        valid_at         |  name   | range_len 
+-------+-------------------------+---------+-----------
+ [1,2) | [2000-01-01,2000-03-01) | one     |        60
+ [1,2) | [2000-03-01,2000-04-01) | one^1   |        31
+ [1,2) | [2000-04-01,2000-06-01) | one     |        61
+ [1,2) | [2000-07-01,2010-01-01) | one     |      3471
+ [2,3) | [2000-06-01,2000-07-01) | three^2 |        30
 (5 rows)
 
 SELECT * FROM temporal_partitioned_3 ORDER BY id, valid_at;
-  name   |  id   |        valid_at         
----------+-------+-------------------------
- three   | [3,4) | [2000-01-01,2000-03-01)
- three^1 | [3,4) | [2000-03-01,2000-04-01)
- three   | [3,4) | [2000-04-01,2000-06-01)
- five^2  | [3,4) | [2000-06-01,2000-07-01)
- three   | [3,4) | [2000-07-01,2010-01-01)
- one^2   | [4,5) | [2000-06-01,2000-07-01)
+  name   |  id   |        valid_at         | range_len 
+---------+-------+-------------------------+-----------
+ three   | [3,4) | [2000-01-01,2000-03-01) |        60
+ three^1 | [3,4) | [2000-03-01,2000-04-01) |        31
+ three   | [3,4) | [2000-04-01,2000-06-01) |        61
+ five^2  | [3,4) | [2000-06-01,2000-07-01) |        30
+ three   | [3,4) | [2000-07-01,2010-01-01) |      3471
+ one^2   | [4,5) | [2000-06-01,2000-07-01) |        30
 (6 rows)
 
 SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;
-  name  |        valid_at         |  id   
---------+-------------------------+-------
- five   | [2000-01-01,2000-03-01) | [5,6)
- five^1 | [2000-03-01,2000-04-01) | [5,6)
- five   | [2000-04-01,2000-06-01) | [5,6)
- five   | [2000-07-01,2010-01-01) | [5,6)
+  name  |        valid_at         |  id   | range_len 
+--------+-------------------------+-------+-----------
+ five   | [2000-01-01,2000-03-01) | [5,6) |        60
+ five^1 | [2000-03-01,2000-04-01) | [5,6) |        31
+ five   | [2000-04-01,2000-06-01) | [5,6) |        61
+ five   | [2000-07-01,2010-01-01) | [5,6) |      3471
 (4 rows)
 
index d4062acf1d1..001e83e4ce4 100644

@@ -1292,6 +1300,7 @@ DROP TABLE for_portion_of_test2;
 DROP TYPE mydaterange;
 
 -- Test FOR PORTION OF against a partitioned table.
+-- Include a GENERATED STORED column to test updatedCols column mapping.
 -- temporal_partitioned_1 has the same attnums as the root
 -- temporal_partitioned_3 has the different attnums from the root
 -- temporal_partitioned_5 has the different attnums too, but reversed
@@ -1300,6 +1309,7 @@ CREATE TABLE temporal_partitioned (
   id int4range,
   valid_at daterange,
   name text,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
   CONSTRAINT temporal_paritioned_uq UNIQUE (id, valid_at WITHOUT OVERLAPS)
 ) PARTITION BY LIST (id);
 CREATE TABLE temporal_partitioned_1 PARTITION OF temporal_partitioned FOR VALUES IN ('[1,2)', '[2,3)');
@@ -1307,13 +1317,15 @@ CREATE TABLE temporal_partitioned_3 PARTITION OF temporal_partitioned FOR VALUES
 CREATE TABLE temporal_partitioned_5 PARTITION OF temporal_partitioned FOR VALUES IN ('[5,6)', '[6,7)');
 
 ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_3;
-ALTER TABLE temporal_partitioned_3 DROP COLUMN id, DROP COLUMN valid_at;
+ALTER TABLE temporal_partitioned_3 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
 ALTER TABLE temporal_partitioned_3 ADD COLUMN id int4range NOT NULL, ADD COLUMN valid_at daterange NOT NULL;
+ALTER TABLE temporal_partitioned_3 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
 ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_3 FOR VALUES IN ('[3,4)', '[4,5)');
 
 ALTER TABLE temporal_partitioned DETACH PARTITION temporal_partitioned_5;
-ALTER TABLE temporal_partitioned_5 DROP COLUMN id, DROP COLUMN valid_at;
+ALTER TABLE temporal_partitioned_5 DROP COLUMN id, DROP COLUMN valid_at CASCADE;
 ALTER TABLE temporal_partitioned_5 ADD COLUMN valid_at daterange NOT NULL, ADD COLUMN id int4range NOT NULL;
+ALTER TABLE temporal_partitioned_5 ADD COLUMN range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
 ALTER TABLE temporal_partitioned ATTACH PARTITION temporal_partitioned_5 FOR VALUES IN ('[5,6)', '[6,7)');
 
 INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
@@ -1365,4 +1377,80 @@ SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;

2.47.3

---

From: jian he <[email-masked]>
Date: Tue, 21 Apr 2026 11:57:59 +0800

> v5 attached.
v5 will cause a segfault.

+ /*

+ updatedCols =

Here, use "perminfo->updatedCols" not "updatedCols", otherwise segfault.
The attached diff based on v5, fixes this issue.

+ALTER TABLE temporal_partitioned_3 ADD COLUMN range_len int GENERATED
ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED;
Slightly refactoring the tests will allow for easier comparison of
range_len values.

---

From: Paul A Jungwirth <[email-masked]>
Date: Tue, 21 Apr 2026 07:59:56 -0700

> Slightly refactoring the tests will allow for easier comparison of

I can't reproduce a segfault. I don't see how that code would lead to
one. Can you share what you're doing to cause it? Simply running the
regression tests doesn't do it for me.

The v5 patch has quite a lot of code shared between both branches. I
was trying to avoid that in the v5 patch. Is there any way to clean it
up?

I like the how the test change makes it easy to verify the range_len column.

Yours,

---

From: jian he <[email-masked]>
Date: Wed, 22 Apr 2026 09:11:18 +0800

> regression tests doesn't do it for me.

Sorry for the noise. After cleaning the cached build directory and
rebuilding, there is now no issue.

+#include "nodes/print.h"
This should be removed from v5.

---

From: Paul A Jungwirth <[email-masked]>
Date: Wed, 22 Apr 2026 11:03:18 -0700

> This should be removed from v5.

Good catch! I removed that line in v7 (attached). I also included your
test change to compute the range len by hand. Also a rebase was
necessary after d3bba04154.

Yours,

-- 
Paul              ~{:-)
[email-masked]
From 6f87310552574465fb34d58b5c0d4ad59902e5c6 Mon Sep 17 00:00:00 2001
From: jian he <[email-masked]>
Date: Fri, 10 Apr 2026 17:01:12 +0800
Subject: [PATCH v7] Fix some problems with UPDATE FOR PORTION OF

Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
Discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
---
 src/backend/executor/execUtils.c             |  34 ++-
 src/backend/executor/nodeModifyTable.c       | 145 ++++++++----
 src/include/nodes/execnodes.h                |   3 +-
 src/test/regress/expected/for_portion_of.out | 221 +++++++++++++++----
 src/test/regress/sql/for_portion_of.sql      |  94 +++++++-
 5 files changed, 398 insertions(+), 99 deletions(-)

diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..363830f0158 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -1408,6 +1408,7 @@ Bitmapset *
 ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 {
 	RTEPermissionInfo *perminfo = GetResultRTEPermissionInfo(relinfo, estate);
+	Bitmapset *updatedCols = perminfo->updatedCols;
 
 	if (perminfo == NULL)
 		return NULL;
@@ -1418,10 +1419,39 @@ ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 		TupleConversionMap *map = ExecGetRootToChildMap(relinfo, estate);
 
 		if (map)

index 4cb057ca4f9..81f5afc9fb7 100644
@@ -198,6 +198,8 @@ static TupleTableSlot *ExecMergeNotMatched(ModifyTableContext *context,

 /*
@@ -1409,7 +1411,6 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ModifyTableState *mtstate = context->mtstate;
 	ModifyTable *node = (ModifyTable *) mtstate->ps.plan;
 	ForPortionOfExpr *forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
-	AttrNumber	rangeAttno;
 	Datum		oldRange;
 	TypeCacheEntry *typcache;
 	ForPortionOfState *fpoState;
@@ -1424,37 +1425,10 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	ReturnSetInfo rsi;
 	bool		didInit = false;
 	bool		shouldFree = false;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
 
 	LOCAL_FCINFO(fcinfo, 2);
 
-		 * our own TupleTableSlot. We do this lazily so that we don't pay the
@@ -1475,21 +1449,13 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 
-	/*

 	/*
 	 * Get the range's type cache entry. This is worth caching for the whole
@@ -1527,12 +1493,20 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 	fcinfo->args[1].isnull = false;
 
 	/*
 
 	/*
 	 * Insert a leftover for each value returned by the without_portion helper
@@ -1601,8 +1575,9 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 			didInit = true;
 		}

 		/*
@@ -4777,6 +4752,18 @@ ExecModifyTable(PlanState *pstate)
 														 false, true);
 		}
 
+		/*
+		 * our own TupleTableSlot. We do this lazily so that we don't pay the
@@ -5860,3 +5847,67 @@ ExecReScanModifyTable(ModifyTableState *node)
 
index 31f772c723d..2edcf9aa7b0 100644
 
 -- Update without moving within partition 1
@@ -2046,55 +2064,166 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-0
       id = '[3,4)'
   WHERE id = '[5,6)';
 -- Update all partitions at once (each with leftovers)
-SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
-  id   |        valid_at         |  name   
--------+-------------------------+---------
- [1,2) | [2000-01-01,2000-03-01) | one
- [1,2) | [2000-03-01,2000-04-01) | one^1
- [1,2) | [2000-04-01,2000-06-01) | one
- [1,2) | [2000-07-01,2010-01-01) | one
- [2,3) | [2000-06-01,2000-07-01) | three^2
- [3,4) | [2000-01-01,2000-03-01) | three
- [3,4) | [2000-03-01,2000-04-01) | three^1
- [3,4) | [2000-04-01,2000-06-01) | three
- [3,4) | [2000-06-01,2000-07-01) | five^2
- [3,4) | [2000-07-01,2010-01-01) | three
- [4,5) | [2000-06-01,2000-07-01) | one^2
- [5,6) | [2000-01-01,2000-03-01) | five
- [5,6) | [2000-03-01,2000-04-01) | five^1
- [5,6) | [2000-04-01,2000-06-01) | five
- [5,6) | [2000-07-01,2010-01-01) | five
+SELECT *, upper(valid_at) - lower(valid_at) FROM temporal_partitioned ORDER BY id, valid_at;
+  id   |        valid_at         |  name   | range_len | ?column? 
+-------+-------------------------+---------+-----------+----------
+ [1,2) | [2000-01-01,2000-03-01) | one     |        60 |       60
+ [1,2) | [2000-03-01,2000-04-01) | one^1   |        31 |       31
+ [1,2) | [2000-04-01,2000-06-01) | one     |        61 |       61
+ [1,2) | [2000-07-01,2010-01-01) | one     |      3471 |     3471
+ [2,3) | [2000-06-01,2000-07-01) | three^2 |        30 |       30
+ [3,4) | [2000-01-01,2000-03-01) | three   |        60 |       60
+ [3,4) | [2000-03-01,2000-04-01) | three^1 |        31 |       31
+ [3,4) | [2000-04-01,2000-06-01) | three   |        61 |       61
+ [3,4) | [2000-06-01,2000-07-01) | five^2  |        30 |       30
+ [3,4) | [2000-07-01,2010-01-01) | three   |      3471 |     3471
+ [4,5) | [2000-06-01,2000-07-01) | one^2   |        30 |       30
+ [5,6) | [2000-01-01,2000-03-01) | five    |        60 |       60
+ [5,6) | [2000-03-01,2000-04-01) | five^1  |        31 |       31
+ [5,6) | [2000-04-01,2000-06-01) | five    |        61 |       61
+ [5,6) | [2000-07-01,2010-01-01) | five    |      3471 |     3471
 (15 rows)

 
 
index d4062acf1d1..8e00a1863a2 100644

 

 
 INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
@@ -1358,11 +1370,87 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-0

-SELECT * FROM temporal_partitioned ORDER BY id, valid_at;
+SELECT *, upper(valid_at) - lower(valid_at) FROM temporal_partitioned ORDER BY id, valid_at;
 SELECT * FROM temporal_partitioned_1 ORDER BY id, valid_at;
 SELECT * FROM temporal_partitioned_3 ORDER BY id, valid_at;
 SELECT * FROM temporal_partitioned_5 ORDER BY id, valid_at;

---

From: Paul A Jungwirth <[email-masked]>
Date: Tue, 5 May 2026 14:50:05 -0700

> necessary after d3bba04154.

This needed a rebase. v8 attached.

Yours,

-- 
Paul              ~{:-)
[email-masked]
From 824c3f3af0f1ed255241ccff74e25a08bda2e815 Mon Sep 17 00:00:00 2001
From: jian he <[email-masked]>
Date: Fri, 10 Apr 2026 17:01:12 +0800
Subject: [PATCH v8] Fix some problems with UPDATE FOR PORTION OF

 
 	if (perminfo == NULL)
 		return NULL;
 
 		if (map)

 /*
 
 	LOCAL_FCINFO(fcinfo, 2);

-	/*

 	/*
 
 	/*
 
 	/*

 		/*
 
+		/*
 
index 0c0a205c44b..91241463991 100644
 
@@ -2046,54 +2064,54 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-0

 
 
 DROP TABLE temporal_partitioned;
@@ -2152,4 +2170,115 @@ SELECT * FROM fpo_rule ORDER BY f1;
 (2 rows)
 
 DROP TABLE fpo_rule;
index fd79a9b78e7..04e0dba6375 100644

 

 
 INSERT INTO temporal_partitioned (id, valid_at, name) VALUES
@@ -1358,7 +1370,7 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-0

@@ -1398,4 +1410,80 @@ SELECT * FROM fpo_rule ORDER BY f1;
 
 DROP TABLE fpo_rule;

---

From: Peter Eisentraut <[email-masked]>
Date: Wed, 6 May 2026 13:39:18 +0200

> This needed a rebase. v8 attached.

This patch fails the injection_points/isolation test for me.  It looks 
like it causes a server crash.  Check please.

---

From: Paul A Jungwirth <[email-masked]>
Date: Wed, 6 May 2026 10:13:39 -0700

> like it causes a server crash.  Check please.

Sorry, I didn't have injection_points enabled, but now I see it too.
The attached v9 fixes it.

Yours,

-- 
Paul              ~{:-)
[email-masked]
From 0325ac58ad1e2f2e8fb1a2007ddadfcdaffdaced Mon Sep 17 00:00:00 2001
From: jian he <[email-masked]>
Date: Fri, 10 Apr 2026 17:01:12 +0800
Subject: [PATCH v9] Fix some problems with UPDATE FOR PORTION OF

Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
Discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
---
 src/backend/executor/execUtils.c             |  36 ++-
 src/backend/executor/nodeModifyTable.c       | 145 ++++++++----
 src/include/nodes/execnodes.h                |   3 +-
 src/test/regress/expected/for_portion_of.out | 221 +++++++++++++++----
 src/test/regress/sql/for_portion_of.sql      |  94 +++++++-
 5 files changed, 400 insertions(+), 99 deletions(-)

diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..ae23c248081 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -1408,20 +1408,52 @@ Bitmapset *
 ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 {
 	RTEPermissionInfo *perminfo = GetResultRTEPermissionInfo(relinfo, estate);
+	Bitmapset *updatedCols;
 
 	if (perminfo == NULL)
 		return NULL;
 
+	updatedCols = perminfo->updatedCols;
+
 	/* Map the columns to child's attribute numbers if needed. */
 	if (relinfo->ri_RootResultRelInfo)
 	{
 		TupleConversionMap *map = ExecGetRootToChildMap(relinfo, estate);
 
 		if (map)

 /*
 
 	LOCAL_FCINFO(fcinfo, 2);

-	/*

 	/*
 
 	/*
 
 	/*

 		/*
 
+		/*

 

 
 
 DROP TABLE fpo_rule;

 

 

 
 DROP TABLE fpo_rule;

---

From: jian he <[email-masked]>
Date: Thu, 7 May 2026 09:14:47 +0800

if (perminfo == NULL)

----------------------------------------------------
v8 crashes because in some cases, `perminfo` is NULL and we are
``perminfo->updatedCols;``

        /*
         * 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 ((((ModifyTable *) context.mtstate->ps.plan)->forPortionOf) &&
            !resultRelInfo->ri_forPortionOf)
        {
            ExecInitForPortionOf(context.mtstate, estate, resultRelInfo);
        }

the comment "partition child" seems not 100% accurate.
Since we also need to consider table inheritance.
Maybe replace it with "child table".

---

From: Chao Li <[email-masked]>
Date: Thu, 7 May 2026 12:34:43 +0800

> On May 7, 2026, at 01:13, Paul A Jungwirth <[email-masked]> wrote:
> 
> <v9-0001-Fix-some-problems-with-UPDATE-FOR-PORTION-OF.patch>

Hi Paul,

I didn’t review this patch earlier because, from the subject, I thought it was only about recomputing generated stored columns. I just noticed that the patch also changes the inheritance-table path, and I posted another patch for the inheritance-table bug. Please see [1].

I tried applying the new tests from my patch on top of this patch, and it looks like this patch still does not fix the multi-inheritance case.

So I’d like to check with you how we should proceed. I think there are two options:

1. Keep this patch focused on the generated-column issue described in the subject, and use my patch to fix the inheritance-table bug.
2. I can continue from this patch and extend it to fix the multi-inheritance case as well.

Please let me know what you prefer.

[1] https://www.postgresql.org/message-id/4245F94D-84F1-4E05-BF81-C458A6CF9901%40gmail.com

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/

---

From: Chao Li <[email-masked]>
Date: Thu, 7 May 2026 13:54:52 +0800

> On May 7, 2026, at 12:34, Chao Li <[email-masked]> wrote:
> 
> 
> 

I just looked into v9 and made a fix in ExecInitForPortionOf() that resolves the bug with multi-inheritance tables. I also added a test case for that.

The inheritance-table bug affects not only UPDATE, but also DELETE, so I added test cases for DELETE as well. Please see 0002 for my changes.

To make each commit self-contained, would you mind moving the code for the inheritance-table fix to 0002? Then you can keep focusing on 0001, and I can continue working on 0002.

PFA v10 - 0001 the same as v9. 0002 fixed a bug with multi-inheritance tables.

(Note, in 0002, there is a comment format change around line 1496, that was done by pgindent.)

Best regards,

---

From: Chao Li <[email-masked]>
Date: Thu, 7 May 2026 15:05:34 +0800

> On May 7, 2026, at 13:54, Chao Li <[email-masked]> wrote:
> 
> 
> 
> <v10-0001-Fix-some-problems-with-UPDATE-FOR-PORTION-OF.patch><v10-0002-Fix-FOR-PORTION-OF-on-inherited-children-with-di.patch>

A few comments for 0001:

1 - execUtils.c
```
 
 		if (map)
```

The comment explicitly says that it is unsafe to mutate perminfo, but bms_add_member() does not always allocate a new bitmapset. So if updatedCols still points to perminfo->updatedCols, then bms_add_member() may mutate perminfo->updatedCols.

To verify that, I added Assert(updatedCols != perminfo->updatedCols); right after the bms_add_member(), then ran "make check". A lot of tests failed, which seems to confirm that perminfo->updatedCols was being mutated.

So, I think we should make a copy the bitmapset before bms_add_member when needed, to make sure perminfo is not mutated, something like:
```
			if (updatedCols == perminfo->updatedCols)
            			updatedCols = bms_copy(updatedCols);

			updatedCols =
				bms_add_member(updatedCols,
							   rangeAttno - FirstLowInvalidHeapAttributeNumber);
```

2 - execUtils.c
```
```

Typo: manualy -> manually

3 - nodeModifyTable.c
```
+		/*
```

I think this comment is stale. It could be a partition child or an inheritance child.

4 - nodeModifyTable.c
```
```

I think the comment should say "each child relation" rather than "each partition".

Best regards,

---

From: Paul A Jungwirth <[email-masked]>
Date: Thu, 7 May 2026 16:26:08 -0700

> > Please let me know what you prefer.

Thanks for your help on this! I agree that separating the patches
would be better.

> PFA v10 - 0001 the same as v9. 0002 fixed a bug with multi-inheritance tables.

I'll post a v11 addressing the feedback in your other email and moving
the fixes for partitions/inheritance.

Yours,

---

From: Paul A Jungwirth <[email-masked]>
Date: Thu, 7 May 2026 16:47:46 -0700

>                                                            rangeAttno - FirstLowInvalidHeapAttributeNumber);

Ah, thanks for catching this! Fixed.

> Typo: manualy -> manually

Fixed.

> I think this comment is stale. It could be a partition child or an inheritance child.

Okay.

> I think the comment should say "each child relation" rather than "each partition".

Okay.

In these v11 patches I've tried to separate (1) the fix for GENERATED
STORED columns and UPDATE OF triggers (2) fixing inheritance and (and
partitions too, for the bugs in #1). I understand why jian he combined
these into one patch: there is some overlap. If you don't like my
separation, let me know.

Yours,

-- 
Paul              ~{:-)
[email-masked]
From d4c094a831d45206f642d817d21d0458cae45096 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email-masked]>
Date: Thu, 7 May 2026 13:05:54 -0700
Subject: [PATCH v11 1/2] Fix FOR PORTION OF column dependency tracking

When FOR PORTION OF changes the application-time column, we need to mark the
column as updated, so that other GENERATED STORED columns recompute if they
depend on it, and similarly so that UPDATE OF triggers fire. We don't simply
record the column in updatedCols of RTEPermissionInfo, because the UPDATE/DELETE
should work even without permission to update that column.

Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
Discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
---
 src/backend/executor/execUtils.c             | 40 +++++++++-
 src/test/regress/expected/for_portion_of.out | 80 ++++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 58 ++++++++++++++
 3 files changed, 176 insertions(+), 2 deletions(-)

diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..6d839e744fc 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -1408,20 +1408,56 @@ Bitmapset *
 ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 {
 	RTEPermissionInfo *perminfo = GetResultRTEPermissionInfo(relinfo, estate);
+	Bitmapset *updatedCols;
 
 	if (perminfo == NULL)
 		return NULL;

 		if (map)
-			return execute_attr_map_cols(map->attrMap, perminfo->updatedCols);
+			updatedCols = execute_attr_map_cols(map->attrMap, updatedCols);
+	}
+
+	/*
+	* For UPDATE ... FOR PORTION OF, the range column is being modified
+	* (narrowed via intersection), but it is not included in updatedCols
+	* because the user does not need UPDATE permission on it. Now manually
+	* add it to updatedCols.
+	*
+	* For partitioned tables, ri_forPortionOf->fp_rangeAttno is already mapped
+	* for the child partition, so we have to add it after the mapping just
+	* above. Also that makes it unsafe to mutate perminfo. We make an explicit
+	* copy of the Bitmapset since bms_add_member may change it in-place.
+	* XXX: Always add the unmapped attno instead (before mapping), and mutate
+	* perminfo, to avoid repeated allocations?
+	*/
+	if (relinfo->ri_forPortionOf)
+	{
+		AttrNumber  rangeAttno = relinfo->ri_forPortionOf->fp_rangeAttno;
+
+		if (!bms_is_member(rangeAttno - FirstLowInvalidHeapAttributeNumber,
+						   updatedCols))
+		{
+			MemoryContext oldContext;
+
+			oldContext = MemoryContextSwitchTo(estate->es_query_cxt);
+
+			updatedCols = bms_copy(updatedCols);
+			updatedCols =
+				bms_add_member(updatedCols,
+							   rangeAttno - FirstLowInvalidHeapAttributeNumber);
+
+			MemoryContextSwitchTo(oldContext);
+		}
 	}

 /* Return a bitmap representing generated columns being updated */
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 0c0a205c44b..094022d53ea 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -2152,4 +2152,84 @@ SELECT * FROM fpo_rule ORDER BY f1;
 (2 rows)
 
 DROP TABLE fpo_rule;
+-- UPDATE FOR PORTION OF with generated stored columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,100) |        90 |         90
+(1 row)
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,30)  |        20 |         20
+  2 | [30,70)  |        40 |         40
+  1 | [70,100) |        30 |         30
+(3 rows)
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,100) |          91 |           91
+(1 row)
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,30)  |          21 |           21
+  2 | [30,70)  |          42 |           42
+  1 | [70,100) |          31 |           31
+(3 rows)
+
+DROP TABLE fpo_generated;
+-- Test that UPDATE OF colname triggers fire if colname is valid_at:
+CREATE TABLE fpo_update_of_trigger (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_update_of_trigger (id, valid_at) VALUES (1, '[10,100)');
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE fpo_update_of_trigger
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+NOTICE:  fpo_before_row2: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [10,100)
+NOTICE:    new: [30,70)
+DROP TABLE fpo_update_of_trigger;
 RESET datestyle;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index fd79a9b78e7..ac5bce553eb 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1398,4 +1398,62 @@ SELECT * FROM fpo_rule ORDER BY f1;
 
 DROP TABLE fpo_rule;
 
+-- UPDATE FOR PORTION OF with generated stored columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+DROP TABLE fpo_generated;
+
+-- Test that UPDATE OF colname triggers fire if colname is valid_at:
+CREATE TABLE fpo_update_of_trigger (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_update_of_trigger (id, valid_at) VALUES (1, '[10,100)');
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE fpo_update_of_trigger
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+DROP TABLE fpo_update_of_trigger;
+
 RESET datestyle;
-- 
2.47.3

From 3d07510ddfaca1e2e8016851a5a8faa101cf863e Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email-masked]>
Date: Thu, 7 May 2026 15:31:12 -0700
Subject: [PATCH v11 2/2] Fix FOR PORTION OF with partitions and inheritance

- Fixed inserting leftovers with traditional table inheritance. Since there is
  no tuple routing, we must add them directly to the child table. Also this
  preserves extra columns in that table.
- Added ExecInitForPortionOf. This sets up executor state for child partitions.
  Previously we did this in ExecForPortionOfLeftovers, but doing it earlier lets
  us use the child->parent attr mapping in updatedCols (used to recompute
  GENERATED STORED columns and call UPDATE OF triggers, if the column has
  changed).
- Clarified a comment about the rangetype stored in ForPortionOfState.

Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
Discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
---
 src/backend/executor/nodeModifyTable.c       | 150 ++++++++----
 src/include/nodes/execnodes.h                |   3 +-
 src/test/regress/expected/for_portion_of.out | 241 +++++++++++++++----
 src/test/regress/sql/for_portion_of.sql      |  91 ++++++-
 4 files changed, 388 insertions(+), 97 deletions(-)

index 4cb057ca4f9..4c31cd80da0 100644

 /*
 
 	LOCAL_FCINFO(fcinfo, 2);

-	/*

 	/*
 
 	/*
-	 * 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.
+	 * 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
+	 * (resultRelInfo) tuple descriptor.
 	 */
-	if (resultRelInfo->ri_RootResultRelInfo)
+	if (rootRelInfo &&
+		rootRelInfo->ri_RelationDesc->rd_rel->relkind == RELKIND_PARTITIONED_TABLE)
+	{
+		map = ExecGetChildToRootMap(resultRelInfo);
 		resultRelInfo = resultRelInfo->ri_RootResultRelInfo;
+	}
 
 	/*
 	 * Insert a leftover for each value returned by the without_portion helper
@@ -1601,8 +1575,8 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 			didInit = true;
 		}

 		/*
@@ -4777,6 +4751,18 @@ ExecModifyTable(PlanState *pstate)
 														 false, true);
 		}
 
+		/*
+		 * If we don't have a ForPortionOfState yet, we must be a partition or
+		 * inheritance 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 ((((ModifyTable *) context.mtstate->ps.plan)->forPortionOf) &&
+			!resultRelInfo->ri_forPortionOf)
+		{
+			ExecInitForPortionOf(context.mtstate, estate, resultRelInfo);
+		}
+
 		/*
 		 * If resultRelInfo->ri_usesFdwDirectModify is true, all we need to do
 		 * here is compute the RETURNING expressions.
@@ -5860,3 +5846,73 @@ ExecReScanModifyTable(ModifyTableState *node)
 	 */
 	elog(ERROR, "ExecReScanModifyTable is not implemented");
 }
+
+/* ----------------------------------------------------------------
+ *		ExecInitForPortionOf
+ *
+ *		Initializes resultRelInfo->ri_forPortionOf for child tables.
+ * ----------------------------------------------------------------
+ */
+static void
+ExecInitForPortionOf(ModifyTableState *mtstate, EState *estate, ResultRelInfo *resultRelInfo)
+{
+	MemoryContext oldcxt;
+	ForPortionOfState *leafState;
+	ResultRelInfo *rootRelInfo = mtstate->rootResultRelInfo;
+	ForPortionOfState *fpoState;
+	TupleConversionMap *map;
+
+	if (!rootRelInfo)
+		elog(ERROR, "no root relation but ri_forPortionOf is uninitialized");
+
+	fpoState = mtstate->rootResultRelInfo->ri_forPortionOf;
+
+	/* Things built here have to last for the query duration. */
+	oldcxt = MemoryContextSwitchTo(estate->es_query_cxt);
+
+	leafState = makeNode(ForPortionOfState);
+
+	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 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)
+	{
+		leafState->fp_Leftover = fpoState->fp_Leftover;
+	}
+	else
+	{
+		leafState->fp_Leftover =
+			ExecInitExtraTupleSlot(mtstate->ps.state,
+								   RelationGetDescr(resultRelInfo->ri_RelationDesc),
+								   &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);
+
+	resultRelInfo->ri_forPortionOf = leafState;
+
+	MemoryContextSwitchTo(oldcxt);
+}
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..53c138310db 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -477,7 +477,8 @@ typedef struct ForPortionOfState
 	NodeTag		type;
 
index 094022d53ea..b93375b8fea 100644

 

 DROP TABLE temporal_partitioned;
@@ -2152,6 +2170,137 @@ SELECT * FROM fpo_rule ORDER BY f1;
 (2 rows)
 
 DROP TABLE fpo_rule;
+-- UPDATE/DELETE FOR PORTION OF with table inheritance
+-- Leftover rows must stay in the child table, preserving child-specific columns.
+CREATE TABLE fpo_inh_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_inh_child (
+  description text
+) INHERITS (fpo_inh_parent);
+-- Update targets the parent; the matching row lives in the child.
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+-- All three rows should be in the child, with description preserved.
+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-04-01,2018-10-01) | one^1
+ fpo_inh_child | [1,2) | [2018-10-01,2019-01-01) | one
+(3 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-04-01,2018-10-01) | one^1 | initial
+ [1,2) | [2018-10-01,2019-01-01) | one   | initial
+(3 rows)
+
+-- No rows should have leaked into the parent.
+SELECT * FROM ONLY fpo_inh_parent ORDER BY valid_at;
+ id | valid_at | name 
+----+----------+------
+(0 rows)
+
+-- Same test for DELETE instead of UPDATE:
+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';
+-- All three rows should be in the child, with description preserved.
+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)
+
+-- No rows should have leaked into the parent.
+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
 -- UPDATE FOR PORTION OF with generated stored columns
 -- The generated column depends on the range column, so it must be
 -- recomputed when FOR PORTION OF narrows the range.
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index ac5bce553eb..316c3f73083 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -913,6 +913,10 @@ CREATE TRIGGER fpo_before_stmt
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);

 

 

@@ -1398,6 +1410,79 @@ SELECT * FROM fpo_rule ORDER BY f1;
 
 DROP TABLE fpo_rule;
 
+-- UPDATE/DELETE FOR PORTION OF with table inheritance
+-- Leftover rows must stay in the child table, preserving child-specific columns.
+CREATE TABLE fpo_inh_parent (
+  id int4range,
+  valid_at daterange,
+  name text
+);
+CREATE TABLE fpo_inh_child (
+  description text
+) INHERITS (fpo_inh_parent);
+
+-- Update targets the parent; the matching row lives in the child.
+INSERT INTO fpo_inh_child (id, valid_at, name, description) VALUES
+  ('[1,2)', '[2018-01-01,2019-01-01)', 'one', 'initial');
+UPDATE fpo_inh_parent FOR PORTION OF valid_at FROM '2018-04-01' TO '2018-10-01'
+  SET name = 'one^1';
+-- All three rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+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;
+
+-- Same test for DELETE instead of UPDATE:
+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';
+-- All three rows should be in the child, with description preserved.
+SELECT tableoid::regclass, * FROM fpo_inh_parent ORDER BY valid_at;
+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;
+
+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;
+
 -- UPDATE FOR PORTION OF with generated stored columns
 -- The generated column depends on the range column, so it must be
 -- recomputed when FOR PORTION OF narrows the range.
-- 
2.47.3

---

From: Chao Li <[email-masked]>
Date: Fri, 8 May 2026 15:09:46 +0800

> On May 8, 2026, at 07:47, Paul A Jungwirth <[email-masked]> wrote:
> 
> <v11-0001-Fix-FOR-PORTION-OF-column-dependency-tracking.patch><v11-0002-Fix-FOR-PORTION-OF-with-partitions-and-inheritan.patch>

Thanks for updating the patch and making the separation. After reading v11, I still have a few comments for 0001.

```

1. I don’t think we should unconditionally do bms_copy, only if (updatedCols == perminfo->updatedCols), we need to make the copy.

2. I doubt if we need to switch to estate->es_query_cxt. Because ExecGetUpdatedCols() is called by ExecGetAllUpdatedCols(), and its header comment says the function runs in per-tuple memory context:
```
/*
* Return columns being updated, including generated columns
*
* The bitmap is allocated in per-tuple memory context. It's up to the caller to
* copy it into a different context with the appropriate lifespan, if needed.
*/
Bitmapset *
ExecGetAllUpdatedCols(ResultRelInfo *relinfo, EState *estate)
```

So I think bms_copy and bms_add_member should be just done in the current memory context.

3. "rangeAttno - FirstLowInvalidHeapAttributeNumber” appears twice, maybe add a local variable to avoid the duplication.

Best regards,

---

From: Paul A Jungwirth <[email-masked]>
Date: Fri, 8 May 2026 08:25:22 -0700

> 1. I don’t think we should unconditionally do bms_copy, only if (updatedCols == perminfo->updatedCols), we need to make the copy.

You're saying we can skip the copy if execute_attr_map_cols already
made a new bms above. That's true. Since we're going to just use the
current memory context (see below), that seems safe.

> So I think bms_copy and bms_add_member should be just done in the current memory context.

Okay. I think using the current memory context is more correct anyway.
There are other callers, and using the query memory context isn't
necessarily what they want. Also the bms (potentially) allocated by
execute_attr_map_cols is in the current memory context, so doing
something different feels surprising. And it's safer not to change the
behavior. Maybe there is a memory leak because of a long-lived
context, but then it exists already. I added a comment to
ExecGetUpdatedCols to call out that we use the current memory context.

Okay.

v12 attached.

Yours,

-- 
Paul              ~{:-)
[email-masked]
From 01cbcfc415f1d1331eaba1ef2fc0f78d9e63a0f0 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email-masked]>
Date: Thu, 7 May 2026 13:05:54 -0700
Subject: [PATCH v12 1/2] Fix FOR PORTION OF column dependency tracking

Discussion: https://postgr.es/m/CAHg+QDcd=t69gLf9yQexO07EJ2mx0Z70NFHo6h94X1EDA=[email-masked]
Discussion: https://postgr.es/m/CAHg+QDcsXsUVaZ+JwM02yDRQEi=[email-masked]
---
 src/backend/executor/execUtils.c             | 39 +++++++++-
 src/test/regress/expected/for_portion_of.out | 80 ++++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 58 ++++++++++++++
 3 files changed, 174 insertions(+), 3 deletions(-)

diff --git a/src/backend/executor/execUtils.c b/src/backend/executor/execUtils.c
index 1eb6b9f1f40..efafebafb5c 100644
--- a/src/backend/executor/execUtils.c
+++ b/src/backend/executor/execUtils.c
@@ -1403,25 +1403,58 @@ ExecGetInsertedCols(ResultRelInfo *relinfo, EState *estate)
 	return perminfo->insertedCols;
 }
 
-/* Return a bitmap representing columns being updated */
+/*
+ * Return a bitmap representing columns being updated.  If we allocate a new
+ * Bitmapset it will be in the current memory context.
+ */
 Bitmapset *
 ExecGetUpdatedCols(ResultRelInfo *relinfo, EState *estate)
 {
 	RTEPermissionInfo *perminfo = GetResultRTEPermissionInfo(relinfo, estate);
+	Bitmapset  *updatedCols;
 
 	if (perminfo == NULL)
 		return NULL;

 		if (map)
-			return execute_attr_map_cols(map->attrMap, perminfo->updatedCols);
+			updatedCols = execute_attr_map_cols(map->attrMap, updatedCols);
+	}
+
+	/*
+	 * For UPDATE ... FOR PORTION OF, the range column is being modified
+	 * (narrowed via intersection), but it is not included in updatedCols
+	 * because the user does not need UPDATE permission on it. Now manually
+	 * add it to updatedCols.
+	 *
+	 * For partitioned tables, ri_forPortionOf->fp_rangeAttno is already
+	 * mapped for the child partition, so we have to add it after the mapping
+	 * just above. Also that makes it unsafe to mutate perminfo. We make an
+	 * explicit copy of the Bitmapset since bms_add_member may change it
+	 * in-place. XXX: Always add the unmapped attno instead (before mapping),
+	 * and mutate perminfo, to avoid repeated allocations?
+	 */
+	if (relinfo->ri_forPortionOf)
+	{
+		AttrNumber	rangeAttno = relinfo->ri_forPortionOf->fp_rangeAttno - FirstLowInvalidHeapAttributeNumber;
+
+		if (!bms_is_member(rangeAttno, updatedCols))
+		{
+			/* Skip the copy if execute_attr_map_cols did it already. */
+			if (updatedCols == perminfo->updatedCols)
+				updatedCols = bms_copy(updatedCols);
+
+			updatedCols = bms_add_member(updatedCols, rangeAttno);
+		}
 	}

 
 DROP TABLE fpo_rule;
 
 DROP TABLE fpo_rule;

From 91a4008e6c38fcaef49d3320abfdeda3d008f200 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email-masked]>
Date: Thu, 7 May 2026 15:31:12 -0700
Subject: [PATCH v12 2/2] Fix FOR PORTION OF with partitions and inheritance

index 4cb057ca4f9..7b7f6b0fc10 100644

 /*
 
 	LOCAL_FCINFO(fcinfo, 2);

-	/*

 	/*
 
 	/*
 
 	/*

 		/*
 
+		/*
+		 * inheritance 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.

 

 
 
 DROP TABLE fpo_rule;

 

 

 
 DROP TABLE fpo_rule;

---

From: Chao Li <[email-masked]>
Date: Sat, 9 May 2026 14:38:03 +0800

> On May 8, 2026, at 23:25, Paul A Jungwirth <[email-masked]> wrote:
> 
> <v12-0001-Fix-FOR-PORTION-OF-column-dependency-tracking.patch><v12-0002-Fix-FOR-PORTION-OF-with-partitions-and-inheritan.patch>

Thanks for updating the patch. I have no more comment. V12 LGTM.

Best regards,

---

From: jian he <[email-masked]>
Date: Mon, 11 May 2026 20:03:17 +0800

> > 2. I doubt if we need to switch to estate->es_query_cxt. Because ExecGetUpdatedCols() is called by ExecGetAllUpdatedCols(), and its header comment says the function runs in per-tuple memory conte…

Switching to estate->es_query_cxt can actually save some cycles.

See ExecGetExtraUpdatedCols->ExecInitGenerated
    /*
     * Make sure these data structures are built in the per-query memory
     * context so they'll survive throughout the query.
     */
    oldContext = MemoryContextSwitchTo(estate->es_query_cxt);

In ExecGetUpdatedCols, we can change it to the following to save some
unnecessary bms_add_member cycle.
``````
    if (relinfo->ri_forPortionOf)
    {
        AttrNumber    rangeAttno = relinfo->ri_forPortionOf->fp_rangeAttno;

        if (!bms_is_member(rangeAttno - FirstLowInvalidHeapAttributeNumber,
                           updatedCols))
        {
            MemoryContext oldContext;

            if (updatedCols != perminfo->updatedCols)
                updatedCols = bms_add_member(updatedCols, rangeAttno -
FirstLowInvalidHeapAttributeNumber);
            else
            {
                oldContext = MemoryContextSwitchTo(estate->es_query_cxt);

                updatedCols = bms_add_member(updatedCols, rangeAttno -
FirstLowInvalidHeapAttributeNumber);

               MemoryContextSwitchTo(oldContext);
            }
        }
    }
``````

---

From: Paul A Jungwirth <[email-masked]>
Date: Tue, 12 May 2026 12:26:01 -0700

>     oldContext = MemoryContextSwitchTo(estate->es_query_cxt);

I agree that seems nice, but it doesn't seem correct if we sometimes
change the context and sometimes not (from execute_attr_map_cols).

Yours,

---

From: Nathan Bossart <[email-masked]>
Date: Tue, 12 May 2026 15:34:18 -0500

FOR PORTION OF doesn't seem to work well with virtual generated columns,
either.  The following example seg-faults on my machine:

    create table t (a int, b int4range generated always as (int4range(a, a + 1)) virtual);
    insert into t values (1);
    delete from t for portion of b from 1 to 2;

-- 
nathan

---

From: Paul A Jungwirth <[email-masked]>
Date: Tue, 12 May 2026 17:05:03 -0700

>     delete from t for portion of b from 1 to 2;

Thanks for catching this!

Here is a patch forbidding both STORED and VIRTUAL columns here. There
is a follow-up patch (not for v19) to add SQL:2011 PERIODs, which will
be based on STORED columns, so we will eventually allow those (if they
belong to a PERIOD), but it seems right to forbid them for now.

I put the check in the analysis phase to match what we have already,
but based on [1] that is apparently premature. I think I'd like to
move all those things together in a single commit though.

I did experiment with putting just this check in ExecInitModifyTable.
But (1) the planner will already reject the UPDATE case with a
different error message, and (2) it doesn't really improve anything,
since rangeVar gets looked up during analysis anyway (until we address
the rest of [1]).

[1] https://www.postgresql.org/message-id/626986.1776785090%40sss.pgh.pa.us

Yours,

--
Paul              ~{:-)
[email-masked]
From f2bbcafc8fe7d1dbdf3c6fd49a34975fca9d804d Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email-masked]>
Date: Tue, 12 May 2026 16:33:42 -0700
Subject: [PATCH v1] Forbid GENERATED columns in FOR PORTION OF

With VIRTUAL columns there is no column to assign to, and we shouldn't
assign directly to STORED columns either. (Once we have PERIODs, we will
allow a STORED column here, but we will assign to its start/end inputs.)

Discussion: https://postgr.es/m/agOOykf2HV26yVfU%40nathan
---
 src/backend/parser/analyze.c                 | 13 +++++++++
 src/test/regress/expected/for_portion_of.out | 30 ++++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 21 ++++++++++++++
 3 files changed, 64 insertions(+)

diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index ffcf25a6be7..12b64b01e18 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -1354,6 +1354,19 @@ transformForPortionOfClause(ParseState *pstate,
 				 parser_errposition(pstate, forPortionOf->location)));
 	attr = TupleDescAttr(targetrel->rd_att, range_attno - 1);
 
+	/*
+	 * Reject generated columns. We can't write to a virtual generated column,
+	 * and a stored generated column should be written by its own expression.
+	 * XXX: We plan to implement PERIODs as stored generated columns, so later
+	 * we will loosen this restriction if the column belongs to a PERIOD.
+	 */
+	if (attr->attgenerated)
+		ereport(ERROR,
+				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				 errmsg("cannot use generated column \"%s\" in FOR PORTION OF",
+						forPortionOf->range_name),
+				 parser_errposition(pstate, forPortionOf->location)));
+
 	attbasetype = getBaseType(attr->atttypid);
 
 	rangeVar = makeVar(rtindex,
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 0c0a205c44b..9979d816972 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -2152,4 +2152,34 @@ SELECT * FROM fpo_rule ORDER BY f1;
 (2 rows)
 
 DROP TABLE fpo_rule;
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED VIRTUAL range column:
+CREATE TABLE fpo_gen_virtual (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) VIRTUAL
+);
+INSERT INTO fpo_gen_virtual VALUES (1);
+DELETE FROM fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+LINE 1: DELETE FROM fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2;
+                                                   ^
+UPDATE fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+LINE 1: UPDATE fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2 SET a = ...
+                                              ^
+DROP TABLE fpo_gen_virtual;
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED STORED range column:
+CREATE TABLE fpo_gen_stored (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_gen_stored VALUES (1);
+DELETE FROM fpo_gen_stored FOR PORTION OF b FROM 1 TO 2; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+LINE 1: DELETE FROM fpo_gen_stored FOR PORTION OF b FROM 1 TO 2;
+                                                  ^
+UPDATE fpo_gen_stored FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+LINE 1: UPDATE fpo_gen_stored FOR PORTION OF b FROM 1 TO 2 SET a = 5...
+                                             ^
+DROP TABLE fpo_gen_stored;
 RESET datestyle;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index fd79a9b78e7..81bdeb8d13a 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1398,4 +1398,25 @@ SELECT * FROM fpo_rule ORDER BY f1;
 
 DROP TABLE fpo_rule;
 
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED VIRTUAL range column:
+CREATE TABLE fpo_gen_virtual (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) VIRTUAL
+);
+INSERT INTO fpo_gen_virtual VALUES (1);
+DELETE FROM fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2; -- fails
+UPDATE fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+DROP TABLE fpo_gen_virtual;
+
+-- UPDATE/DELETE FOR PORTION OF on a GENERATED STORED range column:
+CREATE TABLE fpo_gen_stored (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_gen_stored VALUES (1);
+DELETE FROM fpo_gen_stored FOR PORTION OF b FROM 1 TO 2; -- fails
+UPDATE fpo_gen_stored FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+DROP TABLE fpo_gen_stored;
+
+
 RESET datestyle;
-- 
2.47.3

---

From: Paul A Jungwirth <[email-masked]>
Date: Wed, 13 May 2026 08:42:53 -0700

> [1] https://www.postgresql.org/message-id/626986.1776785090%40sss.pgh.pa.us

I started a new thread for this issue and made a CF entry. Please see:

- https://www.postgresql.org/message-id/CA%2BrenyVRPyP5TNgEBe%3DhRT1ZR3%3DzWCZLXkAwp5xbWeS_TaMxOA%40mail.gmail.com
- https://commitfest.postgresql.org/patch/6764/

Yours,

---

From: Paul A Jungwirth <[email-masked]>
Date: Mon, 25 May 2026 15:23:20 -0700

On Fri, May 8, 2026 at 8:25 AM Paul A Jungwirth
<[email-masked]> wrote:
> . . .

After discussing this as PGConf.dev, Peter and I agreed that we
*should* be checking for UPDATE permission on the application-time
column. So we need to add the attno to updatedCols on the
RTEPermissionInfo. That is great, because it fixes GENERATED column
dependency tracking and also UPDATE OF triggers, without the
complexity of changing ExecGetUpdatedCols.

Note we still do not require INSERT permission for the temporal
leftovers, which is what the SQL Standard says and also makes sense
semantically, since those leftovers represent already-existing
history.

No RLS changes are needed because RLS policies aren't checked based on
*column* changes.

We still need a fix for non-partitioning inheritance, but the patch
becomes a lot simpler.

Here is a patch adding the application-time column to updatedCols,
along with the tests we've written for GENERATED columns and UPDATE OF
triggers.

I'll submit a patch for inheritance on that thread.

-- 
Paul              ~{:-)
[email-masked]
From 28d10700825a5ab84542d22573a55acd7a94399f Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <[email-masked]>
Date: Mon, 25 May 2026 08:58:33 -0700
Subject: [PATCH v13] Require UPDATE permission on FOR PORTION OF column

It seems like the SQL standard does require this after all, and it makes sense
because these columns get changed. (This is not to be confused with *not*
requiring INSERT permission to add the temporal leftovers.)

Adding the column to RTEPermissionInfo->updatedCols also fixes a couple
outstanding bugs from other (non-permission) features using that bitmapset to
detect changes: GENERATED columns and UPDATE OF triggers. I've included test
cases to exercise those scenarios, including on partitioned tables.
---
 src/backend/parser/analyze.c                 |  12 +-
 src/test/regress/expected/for_portion_of.out | 190 ++++++++++++++-----
 src/test/regress/expected/privileges.out     |  12 +-
 src/test/regress/sql/for_portion_of.sql      |  76 +++++++-
 src/test/regress/sql/privileges.sql          |  10 +-
 5 files changed, 241 insertions(+), 59 deletions(-)

diff --git a/src/backend/parser/analyze.c b/src/backend/parser/analyze.c
index ffcf25a6be7..03753b1c5a7 100644
--- a/src/backend/parser/analyze.c
+++ b/src/backend/parser/analyze.c
@@ -1549,6 +1549,7 @@ transformForPortionOfClause(ParseState *pstate,
 		List	   *funcArgs;
 		Node	   *rangeTLEExpr;
 		TargetEntry *tle;
+		RTEPermissionInfo *target_perminfo = pstate->p_target_nsitem->p_perminfo;
 
 		/*
 		 * Whatever operator is used for intersect by temporal foreign keys,
@@ -1598,14 +1599,9 @@ transformForPortionOfClause(ParseState *pstate,
 							  forPortionOf->range_name, false);
 		result->rangeTargetList = lappend(result->rangeTargetList, tle);
 
-		/*
-		 * The range column will change, but you don't need UPDATE permission
-		 * on it, so we don't add to updatedCols here. XXX: If
-		 * https://www.postgresql.org/message-id/CACJufxEtY1hdLcx%3DFhnqp-ERcV1PhbvELG5COy_CZjoEW76ZPQ%40mail.gmail.com
-		 * is merged (only validate CHECK constraints if they depend on one of
-		 * the columns being UPDATEd), we need to make sure that code knows
-		 * that we are updating the application-time column.
-		 */
+		/* Mark the range column as requiring update permissions */
+		target_perminfo->updatedCols = bms_add_member(target_perminfo->updatedCols,
+				range_attno - FirstLowInvalidHeapAttributeNumber);
 	}
 	else
 		result->rangeTargetList = NIL;
index 0c0a205c44b..fe08186bc5b 100644

 

 DROP TABLE temporal_partitioned;
@@ -2152,4 +2170,84 @@ SELECT * FROM fpo_rule ORDER BY f1;
 (2 rows)
 
 DROP TABLE fpo_rule;
+-- UPDATE FOR PORTION OF with generated stored columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,100) |        90 |         90
+(1 row)
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | range_len | range_lenv 
+----+----------+-----------+------------
+  1 | [10,30)  |        20 |         20
+  2 | [30,70)  |        40 |         40
+  1 | [70,100) |        30 |         30
+(3 rows)
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,100) |          91 |           91
+(1 row)
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+ id | valid_at | id_plus_len | id_plus_lenv 
+----+----------+-------------+--------------
+  1 | [10,30)  |          21 |           21
+  2 | [30,70)  |          42 |           42
+  1 | [70,100) |          31 |           31
+(3 rows)
+
+DROP TABLE fpo_generated;
+-- Test that UPDATE OF colname triggers fire if colname is valid_at:
+CREATE TABLE fpo_update_of_trigger (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_update_of_trigger (id, valid_at) VALUES (1, '[10,100)');
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE fpo_update_of_trigger
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+NOTICE:  fpo_before_row2: BEFORE UPDATE STATEMENT:
+NOTICE:    old: <NULL>
+NOTICE:    new: <NULL>
+NOTICE:  fpo_before_row1: BEFORE UPDATE ROW:
+NOTICE:    old: [10,100)
+NOTICE:    new: [30,70)
+DROP TABLE fpo_update_of_trigger;
 RESET datestyle;
diff --git a/src/test/regress/expected/privileges.out b/src/test/regress/expected/privileges.out
index 0de13612818..f6cc1a1029c 100644
--- a/src/test/regress/expected/privileges.out
+++ b/src/test/regress/expected/privileges.out
@@ -1152,16 +1152,26 @@ CREATE TABLE t1 (
   valid_at tsrange,
 	CONSTRAINT t1pk PRIMARY KEY (c1, valid_at WITHOUT OVERLAPS)
 );
--- UPDATE requires select permission on the valid_at column (but not update):
+-- UPDATE requires select and update permission on the valid_at column:
 GRANT SELECT (c1) ON t1 TO regress_priv_user2;
 GRANT UPDATE (c1) ON t1 TO regress_priv_user2;
 GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user3;
 GRANT UPDATE (c1) ON t1 TO regress_priv_user3;
+GRANT SELECT (c1) ON t1 TO regress_priv_user4;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user4;
+GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user5;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user5;
 SET SESSION AUTHORIZATION regress_priv_user2;
 UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
 ERROR:  permission denied for table t1
 SET SESSION AUTHORIZATION regress_priv_user3;
 UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+ERROR:  permission denied for table t1
+SET SESSION AUTHORIZATION regress_priv_user4;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+ERROR:  permission denied for table t1
+SET SESSION AUTHORIZATION regress_priv_user5;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
 SET SESSION AUTHORIZATION regress_priv_user1;
 -- DELETE requires select permission on the valid_at column:
 GRANT DELETE ON t1 TO regress_priv_user2;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index fd79a9b78e7..856e3f91291 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -913,6 +913,10 @@ CREATE TRIGGER fpo_before_stmt
   BEFORE INSERT OR UPDATE OR DELETE ON for_portion_of_test
   FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);

 

 

@@ -1398,4 +1410,62 @@ SELECT * FROM fpo_rule ORDER BY f1;
 
 DROP TABLE fpo_rule;
 
+-- UPDATE FOR PORTION OF with generated stored columns
+-- The generated column depends on the range column, so it must be
+-- recomputed when FOR PORTION OF narrows the range.
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  range_len int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at)) STORED,
+  range_lenv int GENERATED ALWAYS AS (upper(valid_at) - lower(valid_at))
+);
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- After the FOR PORTION OF (FPO) update, all three resulting rows
+-- (leftover-before, updated, and leftover-after) must contain the correct
+-- values for range_len and range_lenv.
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+-- Also test with a generated column that references both a SET column
+-- and the range column.
+DROP TABLE fpo_generated;
+CREATE TABLE fpo_generated (
+  id int,
+  valid_at int4range,
+  id_plus_len int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at)) STORED,
+  id_plus_lenv int GENERATED ALWAYS AS (id + upper(valid_at) - lower(valid_at))
+);
+
+INSERT INTO fpo_generated (id, valid_at) VALUES (1, '[10,100)');
+SELECT * FROM fpo_generated ORDER BY valid_at;
+
+UPDATE fpo_generated
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+SELECT * FROM fpo_generated ORDER BY valid_at;
+DROP TABLE fpo_generated;
+
+-- Test that UPDATE OF colname triggers fire if colname is valid_at:
+CREATE TABLE fpo_update_of_trigger (
+  id int,
+  valid_at int4range
+);
+INSERT INTO fpo_update_of_trigger (id, valid_at) VALUES (1, '[10,100)');
+CREATE TRIGGER fpo_before_row1
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH ROW EXECUTE PROCEDURE dump_trigger(false, false);
+CREATE TRIGGER fpo_before_row2
+  BEFORE UPDATE OF valid_at ON fpo_update_of_trigger
+  FOR EACH STATEMENT EXECUTE PROCEDURE dump_trigger(false, false);
+UPDATE fpo_update_of_trigger
+  FOR PORTION OF valid_at FROM 30 TO 70
+  SET id = 2;
+DROP TABLE fpo_update_of_trigger;
+
 RESET datestyle;
diff --git a/src/test/regress/sql/privileges.sql b/src/test/regress/sql/privileges.sql
index 95a46854b37..6cd9bb840ff 100644
--- a/src/test/regress/sql/privileges.sql
+++ b/src/test/regress/sql/privileges.sql
@@ -790,15 +790,23 @@ CREATE TABLE t1 (
   valid_at tsrange,
 	CONSTRAINT t1pk PRIMARY KEY (c1, valid_at WITHOUT OVERLAPS)
 );
--- UPDATE requires select permission on the valid_at column (but not update):
+-- UPDATE requires select and update permission on the valid_at column:
 GRANT SELECT (c1) ON t1 TO regress_priv_user2;
 GRANT UPDATE (c1) ON t1 TO regress_priv_user2;
 GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user3;
 GRANT UPDATE (c1) ON t1 TO regress_priv_user3;
+GRANT SELECT (c1) ON t1 TO regress_priv_user4;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user4;
+GRANT SELECT (c1, valid_at) ON t1 TO regress_priv_user5;
+GRANT UPDATE (c1, valid_at) ON t1 TO regress_priv_user5;
 SET SESSION AUTHORIZATION regress_priv_user2;
 UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
 SET SESSION AUTHORIZATION regress_priv_user3;
 UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user4;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
+SET SESSION AUTHORIZATION regress_priv_user5;
+UPDATE t1 FOR PORTION OF valid_at FROM '2000-01-01' TO '2001-01-01' SET c1 = '[2,3)';
 SET SESSION AUTHORIZATION regress_priv_user1;
 -- DELETE requires select permission on the valid_at column:
 GRANT DELETE ON t1 TO regress_priv_user2;
-- 
2.47.3

---

From: jian he <[email-masked]>
Date: Thu, 28 May 2026 11:23:45 +0800

> along with the tests we've written for GENERATED columns and UPDATE OF

V13 looks good to me.

one minor issue:
V13 uses the wording "generated stored column(s)", but all other
places use "stored generated column(s)".

---

From: Peter Eisentraut <[email-masked]>
Date: Thu, 4 Jun 2026 11:58:48 +0200

> along with the tests we've written for GENERATED columns and UPDATE OF

committed

---

From: Peter Eisentraut <[email-masked]>
Date: Thu, 4 Jun 2026 12:04:25 +0200

> places use "stored generated column(s)".

I fixed that.  In one case the test actually included both kinds of 
generated columns, so I just removed the "stored".