From 8b98ef1a9c698a762b6b15b498070a0f1a22bfe0 Mon Sep 17 00:00:00 2001
From: "Paul A. Jungwirth" <pj@illuminatedcomputing.com>
Date: Thu, 14 May 2026 09:56:24 -0700
Subject: [PATCH v2] 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.)

It is important to do this check after parse analysis, so the column
can't change after we validate it. For one thing, a BEGIN ATOMIC SQL
function parses the statement far in advance of executing it.

Discussion: https://www.postgresql.org/message-id/agOOykf2HV26yVfU%40nathan
---
 src/backend/executor/nodeModifyTable.c       | 17 +++++
 src/test/regress/expected/for_portion_of.out | 69 ++++++++++++++++++++
 src/test/regress/sql/for_portion_of.sql      | 54 +++++++++++++++
 3 files changed, 140 insertions(+)

diff --git a/src/backend/executor/nodeModifyTable.c b/src/backend/executor/nodeModifyTable.c
index 478cb01783c..b3b1bfd93ae 100644
--- a/src/backend/executor/nodeModifyTable.c
+++ b/src/backend/executor/nodeModifyTable.c
@@ -5587,6 +5587,23 @@ ExecInitModifyTable(ModifyTable *node, EState *estate, int eflags)
 		tupDesc = rootRelInfo->ri_RelationDesc->rd_att;
 		forPortionOf = (ForPortionOfExpr *) node->forPortionOf;
 
+		/*
+		 * Reject generated columns. We can't write to a virtual generated column,
+		 * and a stored generated column should be written by its own expression.
+		 *
+		 * For virtual columns, forPortionOf->rangeVar gets replaced by the planner,
+		 * so it's not actually a Var anymore!
+		 *
+		 * 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 (!IsA(forPortionOf->rangeVar, Var) ||
+			TupleDescAttr(tupDesc, forPortionOf->rangeVar->varattno - 1)->attgenerated)
+			ereport(ERROR,
+					(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+					 errmsg("cannot use generated column \"%s\" in FOR PORTION OF",
+							forPortionOf->range_name)));
+
 		/* Eval the FOR PORTION OF target */
 		if (mtstate->ps.ps_ExprContext == NULL)
 			ExecAssignExprContext(estate, &mtstate->ps);
diff --git a/src/test/regress/expected/for_portion_of.out b/src/test/regress/expected/for_portion_of.out
index 0c0a205c44b..83ad967dfdb 100644
--- a/src/test/regress/expected/for_portion_of.out
+++ b/src/test/regress/expected/for_portion_of.out
@@ -2152,4 +2152,73 @@ 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
+UPDATE fpo_gen_virtual FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  column "b" can only be updated to DEFAULT
+DETAIL:  Column "b" is a generated column.
+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
+UPDATE fpo_gen_stored FOR PORTION OF b FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  column "b" can only be updated to DEFAULT
+DETAIL:  Column "b" is a generated column.
+DROP TABLE fpo_gen_stored;
+-- A new-style SQL function is parsed at CREATE FUNCTION time, but our
+-- generated-column check is in the executor, so it sees the column's
+-- current attgenerated when the function actually runs.
+CREATE TABLE fpo_func_test (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_func_test VALUES (1);
+-- Definition succeeds even though b is a generated column today.
+CREATE FUNCTION fpo_delete() RETURNS void
+  LANGUAGE SQL
+  BEGIN ATOMIC
+    DELETE FROM fpo_func_test FOR PORTION OF b FROM 1 TO 2;
+  END;
+SELECT fpo_delete(); -- fails: b is generated
+ERROR:  cannot use generated column "b" in FOR PORTION OF
+CONTEXT:  SQL function "fpo_delete" statement 1
+-- Drop the generation expression and the same function now succeeds.
+ALTER TABLE fpo_func_test ALTER COLUMN b DROP EXPRESSION;
+SELECT fpo_delete();
+ fpo_delete 
+------------
+ 
+(1 row)
+
+TABLE fpo_func_test ORDER BY a, b;
+ a | b 
+---+---
+(0 rows)
+
+DROP FUNCTION fpo_delete();
+DROP TABLE fpo_func_test;
+-- Foreign tables don't support FOR PORTION OF. Check fires at execution
+-- time (or at planner time for new-style SQL functions), not at parse time.
+CREATE FOREIGN DATA WRAPPER fpo_dummy_fdw;
+CREATE SERVER fpo_dummy_server FOREIGN DATA WRAPPER fpo_dummy_fdw;
+CREATE FOREIGN TABLE fpo_foreign (a int, valid_at int4range)
+  SERVER fpo_dummy_server;
+DELETE FROM fpo_foreign FOR PORTION OF valid_at FROM 1 TO 2; -- fails
+ERROR:  foreign tables don't support FOR PORTION OF
+UPDATE fpo_foreign FOR PORTION OF valid_at FROM 1 TO 2 SET a = 5; -- fails
+ERROR:  foreign tables don't support FOR PORTION OF
+DROP FOREIGN TABLE fpo_foreign;
+DROP SERVER fpo_dummy_server;
+DROP FOREIGN DATA WRAPPER fpo_dummy_fdw;
 RESET datestyle;
diff --git a/src/test/regress/sql/for_portion_of.sql b/src/test/regress/sql/for_portion_of.sql
index fd79a9b78e7..18410e61bd2 100644
--- a/src/test/regress/sql/for_portion_of.sql
+++ b/src/test/regress/sql/for_portion_of.sql
@@ -1398,4 +1398,58 @@ 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;
+
+-- A new-style SQL function is parsed at CREATE FUNCTION time, but our
+-- generated-column check is in the executor, so it sees the column's
+-- current attgenerated when the function actually runs.
+CREATE TABLE fpo_func_test (
+  a int,
+  b int4range GENERATED ALWAYS AS (int4range(a, a + 1)) STORED
+);
+INSERT INTO fpo_func_test VALUES (1);
+-- Definition succeeds even though b is a generated column today.
+CREATE FUNCTION fpo_delete() RETURNS void
+  LANGUAGE SQL
+  BEGIN ATOMIC
+    DELETE FROM fpo_func_test FOR PORTION OF b FROM 1 TO 2;
+  END;
+SELECT fpo_delete(); -- fails: b is generated
+-- Drop the generation expression and the same function now succeeds.
+ALTER TABLE fpo_func_test ALTER COLUMN b DROP EXPRESSION;
+SELECT fpo_delete();
+TABLE fpo_func_test ORDER BY a, b;
+DROP FUNCTION fpo_delete();
+DROP TABLE fpo_func_test;
+
+-- Foreign tables don't support FOR PORTION OF. Check fires at execution
+-- time (or at planner time for new-style SQL functions), not at parse time.
+CREATE FOREIGN DATA WRAPPER fpo_dummy_fdw;
+CREATE SERVER fpo_dummy_server FOREIGN DATA WRAPPER fpo_dummy_fdw;
+CREATE FOREIGN TABLE fpo_foreign (a int, valid_at int4range)
+  SERVER fpo_dummy_server;
+DELETE FROM fpo_foreign FOR PORTION OF valid_at FROM 1 TO 2; -- fails
+UPDATE fpo_foreign FOR PORTION OF valid_at FROM 1 TO 2 SET a = 5; -- fails
+DROP FOREIGN TABLE fpo_foreign;
+DROP SERVER fpo_dummy_server;
+DROP FOREIGN DATA WRAPPER fpo_dummy_fdw;
+
 RESET datestyle;
-- 
2.47.3

