From 050a1e405a95d00fc6bf17cf9bd9079b9131fdb0 Mon Sep 17 00:00:00 2001
From: Ayush Tiwari <ayushtiwari.slg01@gmail.com>
Date: Mon, 27 Apr 2026 05:20:16 +0000
Subject: [PATCH v1 2/2] Detect row type changes in additional composite result
 paths

EEOP_ROW is not the only long-lived executor path that can reuse a
named composite TupleDesc after ALTER TYPE changes the row layout.  A
whole-row Var, a SQL function returning a whole composite result, and a
targetlist SRF returning a composite result can all cache or copy a
descriptor and later form or return a composite Datum using stale layout
information.

Track the typcache TupleDesc identity in those paths and raise the same
"row type has changed" error if the named row type changes before the
cached descriptor is reused.  Add regression coverage for the additional
cursor/DDL/FETCH-again cases that previously crashed.
---
 src/backend/executor/execExpr.c        |  8 ++++
 src/backend/executor/execExprInterp.c  | 36 +++++++++++++++++
 src/backend/executor/execSRF.c         | 49 ++++++++++++++++++++++
 src/backend/executor/functions.c       | 46 +++++++++++++++++++++
 src/include/executor/execExpr.h        |  1 +
 src/include/nodes/execnodes.h          |  2 +
 src/test/regress/expected/rowtypes.out | 56 ++++++++++++++++++++++++++
 src/test/regress/sql/rowtypes.sql      | 39 ++++++++++++++++++
 8 files changed, 237 insertions(+)

diff --git a/src/backend/executor/execExpr.c b/src/backend/executor/execExpr.c
index 8b83e5d71ea..88e271079d6 100644
--- a/src/backend/executor/execExpr.c
+++ b/src/backend/executor/execExpr.c
@@ -3212,6 +3212,14 @@ ExecInitWholeRowVar(ExprEvalStep *scratch, Var *variable, ExprState *state)
 	scratch->d.wholerow.first = true;
 	scratch->d.wholerow.slow = false;
 	scratch->d.wholerow.tupdesc = NULL; /* filled at runtime */
+	if (variable->vartype != RECORDOID)
+	{
+		scratch->d.wholerow.rowcache = palloc_object(ExprEvalRowtypeCache);
+		scratch->d.wholerow.rowcache->cacheptr = NULL;
+		scratch->d.wholerow.rowcache->tupdesc_id = 0;
+	}
+	else
+		scratch->d.wholerow.rowcache = NULL;
 	scratch->d.wholerow.junkFilter = NULL;
 
 	/* update ExprState flags if Var refers to OLD/NEW */
diff --git a/src/backend/executor/execExprInterp.c b/src/backend/executor/execExprInterp.c
index 0ec06e87278..c448e94ecd1 100644
--- a/src/backend/executor/execExprInterp.c
+++ b/src/backend/executor/execExprInterp.c
@@ -2546,6 +2546,26 @@ get_cached_rowtype(Oid type_id, int32 typmod,
 	}
 }
 
+static TypeCacheEntry *
+lookup_named_rowtype_tcache(Oid type_id)
+{
+	TypeCacheEntry *typentry;
+
+	typentry = lookup_type_cache(type_id,
+							  TYPECACHE_TUPDESC |
+							  TYPECACHE_DOMAIN_BASE_INFO);
+	if (typentry->typtype == TYPTYPE_DOMAIN)
+		typentry = lookup_type_cache(typentry->domainBaseType,
+								   TYPECACHE_TUPDESC);
+	if (typentry->tupDesc == NULL)
+		ereport(ERROR,
+				(errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				 errmsg("type %s is not composite",
+						format_type_be(type_id))));
+
+	return typentry;
+}
+
 
 /*
  * Fast-path functions, for very simple expressions
@@ -5495,6 +5515,7 @@ ExecEvalWholeRowVar(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 		 */
 		if (variable->vartype != RECORDOID)
 		{
+			TypeCacheEntry *typentry;
 			TupleDesc	var_tupdesc;
 			TupleDesc	slot_tupdesc;
 
@@ -5513,6 +5534,10 @@ ExecEvalWholeRowVar(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 			 * If vartype is a domain over composite, just look through that
 			 * to the base composite type.
 			 */
+			typentry = lookup_named_rowtype_tcache(variable->vartype);
+			op->d.wholerow.rowcache->cacheptr = typentry;
+			op->d.wholerow.rowcache->tupdesc_id = typentry->tupDesc_identifier;
+
 			var_tupdesc = lookup_rowtype_tupdesc_domain(variable->vartype,
 														-1, false);
 
@@ -5609,6 +5634,17 @@ ExecEvalWholeRowVar(ExprState *state, ExprEvalStep *op, ExprContext *econtext)
 
 		op->d.wholerow.first = false;
 	}
+	else if (variable->vartype != RECORDOID)
+	{
+		TypeCacheEntry *typentry;
+
+		typentry = (TypeCacheEntry *) op->d.wholerow.rowcache->cacheptr;
+		if (typentry->tupDesc_identifier != op->d.wholerow.rowcache->tupdesc_id)
+			ereport(ERROR,
+					(errcode(ERRCODE_DATATYPE_MISMATCH),
+					 errmsg("row type %s has changed",
+							format_type_be(op->d.wholerow.tupdesc->tdtypeid))));
+	}
 
 	/*
 	 * Make sure all columns of the slot are accessible in the slot's
diff --git a/src/backend/executor/execSRF.c b/src/backend/executor/execSRF.c
index 8aedcc6a459..50999b88769 100644
--- a/src/backend/executor/execSRF.c
+++ b/src/backend/executor/execSRF.c
@@ -21,6 +21,7 @@
 #include "access/htup_details.h"
 #include "catalog/objectaccess.h"
 #include "catalog/pg_proc.h"
+#include "catalog/pg_type.h"
 #include "funcapi.h"
 #include "miscadmin.h"
 #include "nodes/nodeFuncs.h"
@@ -45,6 +46,8 @@ static void ExecPrepareTuplestoreResult(SetExprState *sexpr,
 										ExprContext *econtext,
 										Tuplestorestate *resultStore,
 										TupleDesc resultDesc);
+static TypeCacheEntry *lookup_srf_result_tcache(Oid funcrettype);
+static void check_srf_result_rowtype(SetExprState *sexpr);
 static void tupledesc_match(TupleDesc dst_tupdesc, TupleDesc src_tupdesc);
 
 
@@ -543,6 +546,7 @@ restart:
 			{
 				/* We must return the whole tuple as a Datum. */
 				*isNull = false;
+				check_srf_result_rowtype(fcache);
 				return ExecFetchSlotHeapTupleDatum(fcache->funcResultSlot);
 			}
 			else
@@ -643,6 +647,9 @@ restart:
 	{
 		if (*isDone != ExprEndResult)
 		{
+			if (fcache->funcReturnsTuple && !*isNull)
+				check_srf_result_rowtype(fcache);
+
 			/*
 			 * Save the current argument values to re-use on the next call.
 			 */
@@ -669,6 +676,9 @@ restart:
 					 errmsg("table-function protocol for materialize mode was not followed")));
 		if (rsinfo.setResult != NULL)
 		{
+			if (fcache->funcReturnsTuple)
+				check_srf_result_rowtype(fcache);
+
 			/* prepare to return values from the tuplestore */
 			ExecPrepareTuplestoreResult(fcache, econtext,
 										rsinfo.setResult,
@@ -767,6 +777,10 @@ init_sexpr(Oid foid, Oid input_collation, Expr *node,
 			/* Must copy it out of typcache for safety */
 			sexpr->funcResultDesc = CreateTupleDescCopy(tupdesc);
 			sexpr->funcReturnsTuple = true;
+			sexpr->funcResultTypentry = lookup_srf_result_tcache(funcrettype);
+			if (sexpr->funcResultTypentry != NULL)
+				sexpr->funcResultDescId =
+					((TypeCacheEntry *) sexpr->funcResultTypentry)->tupDesc_identifier;
 		}
 		else if (functypclass == TYPEFUNC_SCALAR)
 		{
@@ -870,6 +884,8 @@ ExecPrepareTuplestoreResult(SetExprState *sexpr,
 							TupleDesc resultDesc)
 {
 	sexpr->funcResultStore = resultStore;
+	if (sexpr->funcReturnsTuple)
+		check_srf_result_rowtype(sexpr);
 
 	if (sexpr->funcResultSlot == NULL)
 	{
@@ -932,6 +948,39 @@ ExecPrepareTuplestoreResult(SetExprState *sexpr,
 	}
 }
 
+static TypeCacheEntry *
+lookup_srf_result_tcache(Oid funcrettype)
+{
+	TypeCacheEntry *typentry;
+
+	if (funcrettype == RECORDOID)
+		return NULL;
+
+	typentry = lookup_type_cache(funcrettype,
+							  TYPECACHE_TUPDESC |
+							  TYPECACHE_DOMAIN_BASE_INFO);
+	if (typentry->typtype == TYPTYPE_DOMAIN)
+		typentry = lookup_type_cache(typentry->domainBaseType,
+								   TYPECACHE_TUPDESC);
+	if (typentry->tupDesc == NULL)
+		return NULL;
+
+	return typentry;
+}
+
+static void
+check_srf_result_rowtype(SetExprState *sexpr)
+{
+	TypeCacheEntry *typentry = (TypeCacheEntry *) sexpr->funcResultTypentry;
+
+	if (typentry != NULL &&
+		typentry->tupDesc_identifier != sexpr->funcResultDescId)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATATYPE_MISMATCH),
+				 errmsg("row type %s has changed",
+						format_type_be(sexpr->funcResultDesc->tdtypeid))));
+}
+
 /*
  * Check that function result tuple type (src_tupdesc) matches or can
  * be considered to match what the query expects (dst_tupdesc). If
diff --git a/src/backend/executor/functions.c b/src/backend/executor/functions.c
index 88109348817..89a8b760293 100644
--- a/src/backend/executor/functions.c
+++ b/src/backend/executor/functions.c
@@ -38,6 +38,7 @@
 #include "utils/snapmgr.h"
 #include "utils/syscache.h"
 #include "utils/tuplestore.h"
+#include "utils/typcache.h"
 
 
 /*
@@ -129,6 +130,8 @@ typedef struct SQLFunctionHashEntry
 	char		prokind;		/* prokind from pg_proc row */
 
 	TupleDesc	rettupdesc;		/* result tuple descriptor */
+	TypeCacheEntry *rettupdesc_typentry;
+	uint64		rettupdesc_id;
 
 	List	   *source_list;	/* RawStmts or Queries read from pg_proc */
 	int			num_queries;	/* original length of source_list */
@@ -203,6 +206,8 @@ static Node *sql_fn_resolve_param_name(SQLFunctionParseInfoPtr pinfo,
 									   const char *paramname, int location);
 static SQLFunctionCache *init_sql_fcache(FunctionCallInfo fcinfo,
 										 bool lazyEvalOK);
+static TypeCacheEntry *lookup_sql_fn_retval_tcache(Oid rettype);
+static void check_sql_fn_retval_rowtype(SQLFunctionCachePtr fcache);
 static bool init_execution_state(SQLFunctionCachePtr fcache);
 static void prepare_next_query(SQLFunctionHashEntry *func);
 static void sql_compile_callback(FunctionCallInfo fcinfo,
@@ -1102,6 +1107,10 @@ sql_compile_callback(FunctionCallInfo fcinfo,
 		MemoryContextSwitchTo(hcontext);
 		func->rettupdesc = CreateTupleDescCopy(rettupdesc);
 		MemoryContextSwitchTo(oldcontext);
+
+		func->rettupdesc_typentry = lookup_sql_fn_retval_tcache(rettype);
+		if (func->rettupdesc_typentry != NULL)
+			func->rettupdesc_id = func->rettupdesc_typentry->tupDesc_identifier;
 	}
 
 	/* Fetch the typlen and byval info for the result type */
@@ -1550,6 +1559,7 @@ postquel_get_single_result(TupleTableSlot *slot,
 	{
 		/* We must return the whole tuple as a Datum. */
 		fcinfo->isnull = false;
+		check_sql_fn_retval_rowtype(fcache);
 		value = ExecFetchSlotHeapTupleDatum(slot);
 	}
 	else
@@ -1828,7 +1838,10 @@ fmgr_sql(PG_FUNCTION_ARGS)
 			fcache->tstore = NULL;
 			/* must copy desc because execSRF.c will free it */
 			if (fcache->junkFilter)
+			{
+				check_sql_fn_retval_rowtype(fcache);
 				rsi->setDesc = CreateTupleDescCopy(fcache->junkFilter->jf_cleanTupType);
+			}
 
 			fcinfo->isnull = true;
 			result = (Datum) 0;
@@ -1888,6 +1901,39 @@ fmgr_sql(PG_FUNCTION_ARGS)
 	return result;
 }
 
+static TypeCacheEntry *
+lookup_sql_fn_retval_tcache(Oid rettype)
+{
+	TypeCacheEntry *typentry;
+
+	if (rettype == RECORDOID)
+		return NULL;
+
+	typentry = lookup_type_cache(rettype,
+							  TYPECACHE_TUPDESC |
+							  TYPECACHE_DOMAIN_BASE_INFO);
+	if (typentry->typtype == TYPTYPE_DOMAIN)
+		typentry = lookup_type_cache(typentry->domainBaseType,
+								   TYPECACHE_TUPDESC);
+	if (typentry->tupDesc == NULL)
+		return NULL;
+
+	return typentry;
+}
+
+static void
+check_sql_fn_retval_rowtype(SQLFunctionCachePtr fcache)
+{
+	TypeCacheEntry *typentry = fcache->func->rettupdesc_typentry;
+
+	if (typentry != NULL &&
+		typentry->tupDesc_identifier != fcache->func->rettupdesc_id)
+		ereport(ERROR,
+				(errcode(ERRCODE_DATATYPE_MISMATCH),
+				 errmsg("row type %s has changed",
+						format_type_be(fcache->func->rettupdesc->tdtypeid))));
+}
+
 
 /*
  * error context callback to let us supply a traceback during compile
diff --git a/src/include/executor/execExpr.h b/src/include/executor/execExpr.h
index 532e01b7b6c..e5be3c185bf 100644
--- a/src/include/executor/execExpr.h
+++ b/src/include/executor/execExpr.h
@@ -348,6 +348,7 @@ typedef struct ExprEvalStep
 			bool		first;	/* first time through, need to initialize? */
 			bool		slow;	/* need runtime check for nulls? */
 			TupleDesc	tupdesc;	/* descriptor for resulting tuples */
+			ExprEvalRowtypeCache *rowcache; /* cached descriptor identity */
 			JunkFilter *junkFilter; /* JunkFilter to remove resjunk cols */
 		}			wholerow;
 
diff --git a/src/include/nodes/execnodes.h b/src/include/nodes/execnodes.h
index 13359180d25..8c5c15af9d1 100644
--- a/src/include/nodes/execnodes.h
+++ b/src/include/nodes/execnodes.h
@@ -1006,6 +1006,8 @@ typedef struct SetExprState
 	 */
 	TupleDesc	funcResultDesc;
 	bool		funcReturnsTuple;	/* valid when funcResultDesc isn't NULL */
+	void	   *funcResultTypentry;	/* cached TypeCacheEntry for result rowtype */
+	uint64		funcResultDescId;	/* last-seen tupdesc identifier, or 0 */
 
 	/*
 	 * Remember whether the function is declared to return a set.  This is set
diff --git a/src/test/regress/expected/rowtypes.out b/src/test/regress/expected/rowtypes.out
index 7ede45b320a..f468fd56d37 100644
--- a/src/test/regress/expected/rowtypes.out
+++ b/src/test/regress/expected/rowtypes.out
@@ -1425,3 +1425,59 @@ FETCH c;
 ERROR:  row type cursor_rowtype has changed
 ROLLBACK;
 DROP TYPE cursor_rowtype;
+-- A whole-row Var over a named row type must detect ALTER TYPE too.
+CREATE TYPE cursor_wholerow_type AS (a int, b int);
+CREATE FUNCTION cursor_wholerow_func() RETURNS SETOF cursor_wholerow_type
+LANGUAGE sql AS $$ SELECT i, power(2, 30)::int FROM generate_series(1, 2) i $$;
+BEGIN;
+DECLARE c CURSOR FOR SELECT x FROM cursor_wholerow_func() AS x;
+FETCH c;
+       x        
+----------------
+ (1,1073741824)
+(1 row)
+
+ALTER TYPE cursor_wholerow_type ALTER ATTRIBUTE b TYPE text;
+FETCH c;
+ERROR:  row type cursor_wholerow_type has changed
+ROLLBACK;
+DROP FUNCTION cursor_wholerow_func();
+DROP TYPE cursor_wholerow_type;
+-- A SQL function returning a whole named row must not use its old tupdesc.
+CREATE TYPE cursor_sqlfunc_type AS (a int, b int);
+CREATE FUNCTION cursor_sqlfunc(i int) RETURNS cursor_sqlfunc_type
+LANGUAGE sql AS $$ SELECT i, power(2, 30)::int $$;
+BEGIN;
+DECLARE c CURSOR FOR SELECT cursor_sqlfunc(i) FROM generate_series(1, 2) i;
+FETCH c;
+ cursor_sqlfunc 
+----------------
+ (1,1073741824)
+(1 row)
+
+ALTER TYPE cursor_sqlfunc_type ALTER ATTRIBUTE b TYPE text;
+FETCH c;
+ERROR:  row type cursor_sqlfunc_type has changed
+CONTEXT:  SQL function "cursor_sqlfunc" statement 1
+ROLLBACK;
+DROP FUNCTION cursor_sqlfunc(int);
+DROP TYPE cursor_sqlfunc_type;
+-- A targetlist SRF returning a named row must not use its old tupdesc.
+CREATE TYPE cursor_srf_type AS (a int, b int);
+CREATE FUNCTION cursor_srf_func() RETURNS SETOF cursor_srf_type
+LANGUAGE sql AS $$ SELECT i, power(2, 30)::int FROM generate_series(1, 2) i $$;
+BEGIN;
+DECLARE c CURSOR FOR SELECT cursor_srf_func();
+FETCH c;
+ cursor_srf_func 
+-----------------
+ (1,1073741824)
+(1 row)
+
+ALTER TYPE cursor_srf_type ALTER ATTRIBUTE b TYPE text;
+FETCH c;
+ERROR:  row type cursor_srf_type has changed
+CONTEXT:  SQL function "cursor_srf_func" statement 1
+ROLLBACK;
+DROP FUNCTION cursor_srf_func();
+DROP TYPE cursor_srf_type;
diff --git a/src/test/regress/sql/rowtypes.sql b/src/test/regress/sql/rowtypes.sql
index ec64f968be8..8b4aee23aeb 100644
--- a/src/test/regress/sql/rowtypes.sql
+++ b/src/test/regress/sql/rowtypes.sql
@@ -574,3 +574,42 @@ ALTER TYPE cursor_rowtype ALTER ATTRIBUTE b TYPE text;
 FETCH c;
 ROLLBACK;
 DROP TYPE cursor_rowtype;
+
+-- A whole-row Var over a named row type must detect ALTER TYPE too.
+CREATE TYPE cursor_wholerow_type AS (a int, b int);
+CREATE FUNCTION cursor_wholerow_func() RETURNS SETOF cursor_wholerow_type
+LANGUAGE sql AS $$ SELECT i, power(2, 30)::int FROM generate_series(1, 2) i $$;
+BEGIN;
+DECLARE c CURSOR FOR SELECT x FROM cursor_wholerow_func() AS x;
+FETCH c;
+ALTER TYPE cursor_wholerow_type ALTER ATTRIBUTE b TYPE text;
+FETCH c;
+ROLLBACK;
+DROP FUNCTION cursor_wholerow_func();
+DROP TYPE cursor_wholerow_type;
+
+-- A SQL function returning a whole named row must not use its old tupdesc.
+CREATE TYPE cursor_sqlfunc_type AS (a int, b int);
+CREATE FUNCTION cursor_sqlfunc(i int) RETURNS cursor_sqlfunc_type
+LANGUAGE sql AS $$ SELECT i, power(2, 30)::int $$;
+BEGIN;
+DECLARE c CURSOR FOR SELECT cursor_sqlfunc(i) FROM generate_series(1, 2) i;
+FETCH c;
+ALTER TYPE cursor_sqlfunc_type ALTER ATTRIBUTE b TYPE text;
+FETCH c;
+ROLLBACK;
+DROP FUNCTION cursor_sqlfunc(int);
+DROP TYPE cursor_sqlfunc_type;
+
+-- A targetlist SRF returning a named row must not use its old tupdesc.
+CREATE TYPE cursor_srf_type AS (a int, b int);
+CREATE FUNCTION cursor_srf_func() RETURNS SETOF cursor_srf_type
+LANGUAGE sql AS $$ SELECT i, power(2, 30)::int FROM generate_series(1, 2) i $$;
+BEGIN;
+DECLARE c CURSOR FOR SELECT cursor_srf_func();
+FETCH c;
+ALTER TYPE cursor_srf_type ALTER ATTRIBUTE b TYPE text;
+FETCH c;
+ROLLBACK;
+DROP FUNCTION cursor_srf_func();
+DROP TYPE cursor_srf_type;
-- 
2.43.0

