From 32771fc4418e7e9f7eec3163f8cb0672d2b41893 Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Thu, 16 Jul 2026 13:45:19 +0800
Subject: [PATCH v2] Avoid RETURNING side effects for FOR PORTION OF leftovers

UPDATE/DELETE FOR PORTION OF inserts leftover rows for the untouched
parts of the original row. These hidden inserts should not affect the
command tag or ROW_COUNT, so they call ExecInsert() with canSetTag set
to false.

However, ExecInsert() still processed RETURNING whenever the target
ResultRelInfo had ri_projectReturning set. That caused RETURNING
expressions to be evaluated for leftover rows even though their results
were discarded. As a result, side-effecting expressions such as
nextval() could run once per leftover row in addition to the visible
updated or deleted row.

Skip RETURNING processing in ExecInsert() when it is handling an
internal FOR PORTION OF leftover insert. Use both the presence of a
FOR PORTION OF clause and mtstate->operation == CMD_INSERT for this
check, so visible cross-partition UPDATE inserts in FOR PORTION OF
statements still process RETURNING normally.

Add regression coverage using nextval() to verify that UPDATE and
DELETE FOR PORTION OF evaluate RETURNING only for rows returned to the
user. Also cover a cross-partition UPDATE FOR PORTION OF with RETURNING
to ensure the visible moved row is still returned.

Author: Chao Li <lic@highgo.com>
Reviewed-by: Dean Rasheed <dean.a.rasheed@gmail.com>
Discussion: https://postgr.es/m/07C125E5-F6ED-460C-A394-E6503DAE18FB@gmail.com
---
 src/backend/executor/nodeModifyTable.c       | 17 ++++++++-
 src/test/regress/expected/for_portion_of.out | 39 +++++++++++++++-----
 src/test/regress/sql/for_portion_of.sql      | 12 ++++--
 3 files changed, 54 insertions(+), 14 deletions(-)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index b9781eb3b95..af682b68f04 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -889,6 +889,16 @@ ExecInsert(ModifyTableContext *context,
 	OnConflictAction onconflict = node->onConflictAction;
 	PartitionTupleRouting *proute = mtstate->mt_partition_tuple_routing;
 	MemoryContext oldContext;
+	bool		fpoLeftover;
+
+	/*
+	 * In FOR PORTION OF, hidden leftover rows are inserted after
+	 * ExecForPortionOfLeftovers() temporarily changes the ModifyTable
+	 * operation from UPDATE or DELETE to INSERT.  A visible cross-partition
+	 * UPDATE can also call ExecInsert(), but it leaves the operation as
+	 * UPDATE.
+	 */
+	fpoLeftover = node->forPortionOf && mtstate->operation == CMD_INSERT;
 
 	/*
 	 * If the input result relation is a partitioned table, find the leaf
@@ -1335,8 +1345,11 @@ ExecInsert(ModifyTableContext *context,
 	if (resultRelInfo->ri_WithCheckOptions != NIL)
 		ExecWithCheckOptions(WCO_VIEW_CHECK, resultRelInfo, slot, estate);
 
-	/* Process RETURNING if present */
-	if (resultRelInfo->ri_projectReturning)
+	/*
+	 * Process RETURNING if present, except for internal FOR PORTION OF
+	 * leftovers
+	 */
+	if (!fpoLeftover && resultRelInfo->ri_projectReturning)
 	{
 		TupleTableSlot *oldSlot = NULL;
 
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index a050fc2dadf..880ebc27851 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -920,14 +920,21 @@ SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
 \set QUIET true
 -- UPDATE ... RETURNING returns only the updated values
 -- (not the inserted side values, which are added by a separate "statement"):
+CREATE SEQUENCE fpo_returning_seq;
 UPDATE for_portion_of_test
   FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-02-15'
   SET name = 'three^3'
   WHERE id = '[3,4)'
-  RETURNING *;
-  id   |        valid_at         |  name   
--------+-------------------------+---------
- [3,4) | [2018-02-01,2018-02-15) | three^3
+  RETURNING *, nextval('fpo_returning_seq') AS seq;
+  id   |        valid_at         |  name   | seq 
+-------+-------------------------+---------+-----
+ [3,4) | [2018-02-01,2018-02-15) | three^3 |   1
+(1 row)
+
+SELECT last_value FROM fpo_returning_seq;
+ last_value 
+------------
+          1
 (1 row)
 
 -- UPDATE ... RETURNING supports NEW and OLD valid_at
@@ -972,15 +979,23 @@ ERROR:  FOR PORTION OF bounds cannot contain volatile functions
 DELETE FROM for_portion_of_test WHERE id = '[99,100)';
 -- DELETE ... RETURNING returns the deleted values, regardless of bounds
 -- (not the inserted side values, which are added by a separate "statement"):
+ALTER SEQUENCE fpo_returning_seq RESTART;
 DELETE FROM for_portion_of_test
   FOR PORTION OF valid_at FROM '2018-02-02' TO '2018-02-03'
   WHERE id = '[3,4)'
-  RETURNING *;
-  id   |        valid_at         |  name   
--------+-------------------------+---------
- [3,4) | [2018-02-01,2018-02-10) | three^3
+  RETURNING *, nextval('fpo_returning_seq') AS seq;
+  id   |        valid_at         |  name   | seq 
+-------+-------------------------+---------+-----
+ [3,4) | [2018-02-01,2018-02-10) | three^3 |   1
 (1 row)
 
+SELECT last_value FROM fpo_returning_seq;
+ last_value 
+------------
+          1
+(1 row)
+
+DROP SEQUENCE fpo_returning_seq;
 -- DELETE FOR PORTION OF in a PL/pgSQL function
 INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
   ('[10,11)', '[2018-01-01,2020-01-01)', 'ten');
@@ -2090,7 +2105,13 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-0
 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
   SET name = 'one^2',
       id = '[4,5)'
-  WHERE id = '[1,2)';
+  WHERE id = '[1,2)'
+  RETURNING id, valid_at, name;
+  id   |        valid_at         | name  
+-------+-------------------------+-------
+ [4,5) | [2000-06-01,2000-07-01) | one^2
+(1 row)
+
 -- Move from partition 3 to partition 1
 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
   SET name = 'three^2',
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index a1ee1d5e501..ea7c0ecbe66 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -590,11 +590,13 @@ SELECT * FROM for_portion_of_test ORDER BY id, valid_at;
 
 -- UPDATE ... RETURNING returns only the updated values
 -- (not the inserted side values, which are added by a separate "statement"):
+CREATE SEQUENCE fpo_returning_seq;
 UPDATE for_portion_of_test
   FOR PORTION OF valid_at FROM '2018-02-01' TO '2018-02-15'
   SET name = 'three^3'
   WHERE id = '[3,4)'
-  RETURNING *;
+  RETURNING *, nextval('fpo_returning_seq') AS seq;
+SELECT last_value FROM fpo_returning_seq;
 
 -- UPDATE ... RETURNING supports NEW and OLD valid_at
 UPDATE for_portion_of_test
@@ -626,10 +628,13 @@ DELETE FROM for_portion_of_test WHERE id = '[99,100)';
 
 -- DELETE ... RETURNING returns the deleted values, regardless of bounds
 -- (not the inserted side values, which are added by a separate "statement"):
+ALTER SEQUENCE fpo_returning_seq RESTART;
 DELETE FROM for_portion_of_test
   FOR PORTION OF valid_at FROM '2018-02-02' TO '2018-02-03'
   WHERE id = '[3,4)'
-  RETURNING *;
+  RETURNING *, nextval('fpo_returning_seq') AS seq;
+SELECT last_value FROM fpo_returning_seq;
+DROP SEQUENCE fpo_returning_seq;
 
 -- DELETE FOR PORTION OF in a PL/pgSQL function
 INSERT INTO for_portion_of_test (id, valid_at, name) VALUES
@@ -1392,7 +1397,8 @@ UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-03-01' TO '2000-0
 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
   SET name = 'one^2',
       id = '[4,5)'
-  WHERE id = '[1,2)';
+  WHERE id = '[1,2)'
+  RETURNING id, valid_at, name;
 
 -- Move from partition 3 to partition 1
 UPDATE temporal_partitioned FOR PORTION OF valid_at FROM '2000-06-01' TO '2000-07-01'
-- 
2.50.1 (Apple Git-155)

