From 9b80feb39ec3222172b7d4100915f5759827bcc9 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 v1] 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.

Add a separate ExecInsert() parameter to control whether RETURNING
should be processed, and disable it for FOR PORTION OF leftover inserts.
Keep canSetTag limited to its existing command-tag and row-count role.

Add regression coverage using nextval() to verify that UPDATE and
DELETE FOR PORTION OF evaluate RETURNING only for the rows returned to
the user.

Author: Chao Li <lic@highgo.com>
---
 src/backend/executor/nodeModifyTable.c       | 17 ++++++-----
 src/test/regress/expected/for_portion_of.out | 31 +++++++++++++++-----
 src/test/regress/sql/for_portion_of.sql      |  9 ++++--
 3 files changed, 40 insertions(+), 17 deletions(-)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index b9781eb3b95..481d5e79523 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -860,7 +860,8 @@ ExecGetUpdateNewTuple(ResultRelInfo *relinfo,
  *
  *		slot contains the new tuple value to be stored.
  *
- *		Returns RETURNING result if any, otherwise NULL.
+ *		Returns RETURNING result if processReturning is true and a RETURNING
+ *		list is present, otherwise NULL.
  *		*inserted_tuple is the tuple that's effectively inserted;
  *		*insert_destrel is the relation where it was inserted.
  *		These are only set on success.
@@ -875,6 +876,7 @@ ExecInsert(ModifyTableContext *context,
 		   ResultRelInfo *resultRelInfo,
 		   TupleTableSlot *slot,
 		   bool canSetTag,
+		   bool processReturning,
 		   TupleTableSlot **inserted_tuple,
 		   ResultRelInfo **insert_destrel)
 {
@@ -1335,8 +1337,8 @@ 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 requested and present */
+	if (processReturning && resultRelInfo->ri_projectReturning)
 	{
 		TupleTableSlot *oldSlot = NULL;
 
@@ -1606,7 +1608,8 @@ ExecForPortionOfLeftovers(ModifyTableContext *context,
 		AfterTriggerBeginQuery();
 		ExecSetupTransitionCaptureState(mtstate, estate);
 		fireBSTriggers(mtstate);
-		ExecInsert(context, resultRelInfo, leftoverSlot, false, NULL, NULL);
+		ExecInsert(context, resultRelInfo, leftoverSlot, false, false,
+				   NULL, NULL);
 		fireASTriggers(mtstate);
 		AfterTriggerEndQuery(estate);
 	}
@@ -2345,7 +2348,7 @@ ExecCrossPartitionUpdate(ModifyTableContext *context,
 
 	/* Tuple routing starts from the root table. */
 	context->cpUpdateReturningSlot =
-		ExecInsert(context, mtstate->rootResultRelInfo, slot, canSetTag,
+		ExecInsert(context, mtstate->rootResultRelInfo, slot, canSetTag, true,
 				   inserted_tuple, insert_destrel);
 
 	/*
@@ -4127,7 +4130,7 @@ ExecMergeNotMatched(ModifyTableContext *context, ResultRelInfo *resultRelInfo,
 				mtstate->mt_merge_action = action;
 
 				rslot = ExecInsert(context, mtstate->rootResultRelInfo,
-								   newslot, canSetTag, NULL, NULL);
+								   newslot, canSetTag, true, NULL, NULL);
 				mtstate->mt_merge_inserted += 1;
 				break;
 			case CMD_NOTHING:
@@ -4968,7 +4971,7 @@ ExecModifyTable(PlanState *pstate)
 					ExecInitInsertProjection(node, resultRelInfo);
 				slot = ExecGetInsertNewTuple(resultRelInfo, context.planSlot);
 				slot = ExecInsert(&context, resultRelInfo, slot,
-								  node->canSetTag, NULL, NULL);
+								  node->canSetTag, true, NULL, NULL);
 				break;
 
 			case CMD_UPDATE:
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index a050fc2dadf..c174022ef7e 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');
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index a1ee1d5e501..e5d80de5f31 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
-- 
2.50.1 (Apple Git-155)

