From bd2f4aff350aab947ab66cdad7afa910f1ede7a1 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Sat, 4 Apr 2026 10:26:44 +0500
Subject: [PATCH v2026-04-04] Fix incorrect results when composite type is
 altered mid-cursor-scan

HeapTuples carry only the type OID, not a schema version.  If ALTER TYPE
changes a composite type between FETCHes, the stored tuples are
interpreted with the wrong definition.

At cursor open, record the tupDesc_identifier of every composite type
reachable from the result columns (including nested ones).  Reject the
FETCH with ERRCODE_INVALID_CURSOR_STATE if any identifier has changed.

Reported-by: Yuxiao Guo <dllggyx@outlook.com>
Discussion: https://www.postgresql.org/message-id/CAOVWO5oRGPd7mA3d85jNYmjLNfeBAca5oDcHTfRFxbAwPLxs5g@mail.gmail.com
---
 src/backend/tcop/pquery.c              | 130 +++++++++++++++++++++++++
 src/include/utils/portal.h             |   5 +
 src/test/regress/expected/rowtypes.out |  54 ++++++++++
 src/test/regress/sql/rowtypes.sql      |  36 +++++++
 4 files changed, 225 insertions(+)

diff --git a/src/backend/tcop/pquery.c b/src/backend/tcop/pquery.c
index d8fc75d0bb9..07cbf288936 100644
--- a/src/backend/tcop/pquery.c
+++ b/src/backend/tcop/pquery.c
@@ -25,8 +25,12 @@
 #include "pg_trace.h"
 #include "tcop/pquery.h"
 #include "tcop/utility.h"
+#include "catalog/pg_type_d.h"
+#include "utils/builtins.h"
+#include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/snapmgr.h"
+#include "utils/typcache.h"
 
 
 /*
@@ -425,6 +429,103 @@ FetchStatementTargetList(Node *stmt)
  * On return, portal is ready to accept PortalRun() calls, and the result
  * tupdesc (if any) is known.
  */
+
+/*
+ * CollectCompositeTypeVersions
+ *		Record typid's tupDesc_identifier, then recurse into its composite-type
+ *		attributes.  Duplicate OIDs are skipped.  Arrays are repalloc'd as
+ *		needed; n/alloc are updated in place.
+ */
+static void
+CollectCompositeTypeVersions(Oid typid,
+							 Oid **oids, uint64 **versions,
+							 int *n, int *alloc)
+{
+	TypeCacheEntry *typentry;
+	TupleDesc	tupdesc;
+
+	for (int i = 0; i < *n; i++)	/* skip if already recorded */
+		if ((*oids)[i] == typid)
+			return;
+
+	typentry = lookup_type_cache(typid, TYPECACHE_TUPDESC);
+
+	if (*n >= *alloc)
+	{
+		*alloc *= 2;
+		*oids = repalloc(*oids, *alloc * sizeof(Oid));
+		*versions = repalloc(*versions, *alloc * sizeof(uint64));
+	}
+
+	(*oids)[*n] = typid;
+	(*versions)[*n] = typentry->tupDesc_identifier;
+	(*n)++;
+
+	tupdesc = typentry->tupDesc;
+	if (tupdesc == NULL)
+		return;
+
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, i);
+
+		if (!attr->attisdropped &&
+			attr->atttypid != RECORDOID &&
+			get_typtype(attr->atttypid) == TYPTYPE_COMPOSITE)
+			CollectCompositeTypeVersions(attr->atttypid,
+										 oids, versions, n, alloc);
+	}
+}
+
+/*
+ * InitPortalCompositeTypeVersions
+ *		Snapshot tupDesc_identifier for every named composite type reachable
+ *		from portal->tupDesc (including nested types).  Called once at cursor
+ *		open; checked at each FETCH to detect mid-scan ALTER TYPE.
+ */
+static void
+InitPortalCompositeTypeVersions(Portal portal)
+{
+	TupleDesc	tupdesc = portal->tupDesc;
+	MemoryContext oldcxt;
+	int			alloc = 8;
+	int			n = 0;
+	Oid		   *oids;
+	uint64	   *versions;
+
+	if (tupdesc == NULL)
+		return;
+
+	oldcxt = MemoryContextSwitchTo(portal->portalContext);
+	oids = palloc(alloc * sizeof(Oid));
+	versions = palloc(alloc * sizeof(uint64));
+
+	for (int i = 0; i < tupdesc->natts; i++)
+	{
+		Form_pg_attribute attr = TupleDescAttr(tupdesc, i);
+
+		if (!attr->attisdropped &&
+			attr->atttypid != RECORDOID &&
+			get_typtype(attr->atttypid) == TYPTYPE_COMPOSITE)
+			CollectCompositeTypeVersions(attr->atttypid,
+										 &oids, &versions, &n, &alloc);
+	}
+
+	MemoryContextSwitchTo(oldcxt);
+
+	if (n > 0)
+	{
+		portal->nCursorCompositeTypes = n;
+		portal->cursorCompositeTypeOids = oids;
+		portal->cursorCompositeTypeVersions = versions;
+	}
+	else
+	{
+		pfree(oids);
+		pfree(versions);
+	}
+}
+
 void
 PortalStart(Portal portal, ParamListInfo params,
 			int eflags, Snapshot snapshot)
@@ -522,6 +623,13 @@ PortalStart(Portal portal, ParamListInfo params,
 				 */
 				portal->tupDesc = queryDesc->tupDesc;
 
+				/*
+				 * Record type-cache versions for any named composite-type
+				 * result columns so that FETCH can detect mid-scan ALTER
+				 * TYPE.
+				 */
+				InitPortalCompositeTypeVersions(portal);
+
 				/*
 				 * Reset cursor position data to "start of query"
 				 */
@@ -1383,6 +1491,28 @@ PortalRunFetch(Portal portal,
 
 	Assert(PortalIsValid(portal));
 
+	/*
+	 * Reject the fetch if any composite type in the result has been altered
+	 * since the cursor was opened; HeapTuples carry no type-version tag so
+	 * the mismatch cannot be caught later.
+	 */
+	if (portal->nCursorCompositeTypes > 0)
+	{
+		for (int i = 0; i < portal->nCursorCompositeTypes; i++)
+		{
+			Oid			typid = portal->cursorCompositeTypeOids[i];
+			TypeCacheEntry *typentry =
+				lookup_type_cache(typid, TYPECACHE_TUPDESC);
+
+			if (typentry->tupDesc_identifier != portal->cursorCompositeTypeVersions[i])
+				ereport(ERROR,
+						(errcode(ERRCODE_INVALID_CURSOR_STATE),
+						 errmsg("cursor scan cannot continue after composite type \"%s\" was altered",
+								format_type_be(typid)),
+						 errhint("Close and reopen the cursor after ALTER TYPE.")));
+		}
+	}
+
 	/*
 	 * Check for improper portal use, and mark portal active.
 	 */
diff --git a/src/include/utils/portal.h b/src/include/utils/portal.h
index a7bedb12c18..a8d725a35ad 100644
--- a/src/include/utils/portal.h
+++ b/src/include/utils/portal.h
@@ -160,6 +160,11 @@ typedef struct PortalData
 	/* and these are the format codes to use for the columns: */
 	int16	   *formats;		/* a format code for each column */
 
+	/* tupDesc_identifier snapshots for composite types in the result columns */
+	int			nCursorCompositeTypes;	/* 0 if none */
+	Oid		   *cursorCompositeTypeOids;
+	uint64	   *cursorCompositeTypeVersions;
+
 	/*
 	 * Outermost ActiveSnapshot for execution of the portal's queries.  For
 	 * all but a few utility commands, we require such a snapshot to exist.
diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out
index 956bc2d02fc..7f4c0567fce 100644
--- a/src/test/regress/expected/rowtypes.out
+++ b/src/test/regress/expected/rowtypes.out
@@ -1408,3 +1408,57 @@ ERROR:  column "oid" not found in data type compositetable
 LINE 1: SELECT (NULL::compositetable).oid;
                 ^
 DROP TABLE compositetable;
+-- ALTER TYPE mid-cursor-scan must be detected and raise an error.
+CREATE TYPE mycomptype AS (a INT, b INT);
+BEGIN;
+DECLARE cur1 CURSOR FOR
+    SELECT (i, i * 100)::mycomptype FROM generate_series(1, 5) i;
+FETCH cur1;
+   row   
+---------
+ (1,100)
+(1 row)
+
+ALTER TYPE mycomptype ALTER ATTRIBUTE b TYPE TEXT;
+FETCH cur1;
+ERROR:  cursor scan cannot continue after composite type "mycomptype" was altered
+HINT:  Close and reopen the cursor after ALTER TYPE.
+COMMIT;
+DROP TYPE mycomptype;
+-- Same check applies when a nested composite type is altered.
+CREATE TYPE myinnertype AS (x INT, y INT);
+CREATE TYPE myoutertype AS (a INT, b myinnertype);
+BEGIN;
+DECLARE cur2 CURSOR FOR
+    SELECT (i, (i * 10, i * 100)::myinnertype)::myoutertype
+    FROM generate_series(1, 5) i;
+FETCH cur2;
+      row       
+----------------
+ (1,"(10,100)")
+(1 row)
+
+ALTER TYPE myinnertype ALTER ATTRIBUTE y TYPE TEXT;
+FETCH cur2;
+ERROR:  cursor scan cannot continue after composite type "myinnertype" was altered
+HINT:  Close and reopen the cursor after ALTER TYPE.
+COMMIT;
+DROP TYPE myoutertype;
+DROP TYPE myinnertype;
+-- MOVE goes through the same portal path and must also be rejected.
+CREATE TYPE mycomptype2 AS (a INT, b INT);
+BEGIN;
+DECLARE cur3 CURSOR FOR
+    SELECT (i, i)::mycomptype2 FROM generate_series(1, 10) i;
+FETCH cur3;
+  row  
+-------
+ (1,1)
+(1 row)
+
+ALTER TYPE mycomptype2 ALTER ATTRIBUTE b TYPE TEXT;
+MOVE cur3;
+ERROR:  cursor scan cannot continue after composite type "mycomptype2" was altered
+HINT:  Close and reopen the cursor after ALTER TYPE.
+COMMIT;
+DROP TYPE mycomptype2;
diff --git a/src/test/regress/sql/rowtypes.sql b/src/test/regress/sql/rowtypes.sql
index 174b062144a..4fad2bc1719 100644
--- a/src/test/regress/sql/rowtypes.sql
+++ b/src/test/regress/sql/rowtypes.sql
@@ -562,3 +562,39 @@ SELECT (NULL::compositetable).a;
 SELECT (NULL::compositetable).oid;
 
 DROP TABLE compositetable;
+
+-- ALTER TYPE mid-cursor-scan must be detected and raise an error.
+CREATE TYPE mycomptype AS (a INT, b INT);
+BEGIN;
+DECLARE cur1 CURSOR FOR
+    SELECT (i, i * 100)::mycomptype FROM generate_series(1, 5) i;
+FETCH cur1;
+ALTER TYPE mycomptype ALTER ATTRIBUTE b TYPE TEXT;
+FETCH cur1;
+COMMIT;
+DROP TYPE mycomptype;
+
+-- Same check applies when a nested composite type is altered.
+CREATE TYPE myinnertype AS (x INT, y INT);
+CREATE TYPE myoutertype AS (a INT, b myinnertype);
+BEGIN;
+DECLARE cur2 CURSOR FOR
+    SELECT (i, (i * 10, i * 100)::myinnertype)::myoutertype
+    FROM generate_series(1, 5) i;
+FETCH cur2;
+ALTER TYPE myinnertype ALTER ATTRIBUTE y TYPE TEXT;
+FETCH cur2;
+COMMIT;
+DROP TYPE myoutertype;
+DROP TYPE myinnertype;
+
+-- MOVE goes through the same portal path and must also be rejected.
+CREATE TYPE mycomptype2 AS (a INT, b INT);
+BEGIN;
+DECLARE cur3 CURSOR FOR
+    SELECT (i, i)::mycomptype2 FROM generate_series(1, 10) i;
+FETCH cur3;
+ALTER TYPE mycomptype2 ALTER ATTRIBUTE b TYPE TEXT;
+MOVE cur3;
+COMMIT;
+DROP TYPE mycomptype2;
-- 
2.51.2

