From d4d6f39ee37e4e6b0378cdb17c11197230d97851 Mon Sep 17 00:00:00 2001
From: Surya Poondla <suryapoondla4@gmail.com>
Date: Mon, 13 Jul 2026 13:24:32 -0700
Subject: [PATCH v11 3/4] Cover generic RECORD variables in ALTER TYPE
 detection (bug #19382)

Previously the check keyed off the variable's declared type (rectypeid),
so a variable declared as generic RECORD -- whose rectypeid is RECORDOID
-- was silently skipped.  Zsolt Parragi reported that the same crash
mechanism still fires in that case:

    r record;
    r := ROW(123, power(2,30)::int4)::foo;
    ALTER TYPE foo ALTER ATTRIBUTE b TYPE text;
    RETURN r;              -- crashes on master and prior patches

Use an "effective root type" in both the snapshot and check functions.
For a named-composite variable the declared type is used, as before.
For a RECORD variable the type is taken from the ExpandedRecord's
er_typeid once the record has been assigned, so the actual composite
type adopted from the assigned value (via ::foo, INTO from a query
producing a named row, etc.) is tracked.

Truly anonymous rowtypes (er_typeid still RECORDOID) are still skipped,
since the type cache does not version them and there is nothing to
compare against.

The three call sites that guard on rec->rectypeid != RECORDOID (in
exec_stmt_return, exec_stmt_return_next, and exec_eval_datum) are
relaxed to drop that condition and let the check function decide
internally whether the outer-type comparison applies.
---
 .../plpgsql/src/expected/plpgsql_record.out   | 65 +++++++++++++++++++
 src/pl/plpgsql/src/pl_exec.c                  | 42 +++++++++---
 src/pl/plpgsql/src/sql/plpgsql_record.sql     | 58 +++++++++++++++++
 3 files changed, 155 insertions(+), 10 deletions(-)

diff --git a/src/pl/plpgsql/src/expected/plpgsql_record.out b/src/pl/plpgsql/src/expected/plpgsql_record.out
index e6b5827ef91..d1b6ce06e36 100644
--- a/src/pl/plpgsql/src/expected/plpgsql_record.out
+++ b/src/pl/plpgsql/src/expected/plpgsql_record.out
@@ -1040,3 +1040,68 @@ select bug19382_test_reassign();
 drop function bug19382_test_reassign();
 drop type bug19382_outer3 cascade;
 drop type bug19382_inner3 cascade;
+-- Case 10: Generic RECORD variable assigned via ROW::foo cast
+-- The declared type is RECORDOID; effective type is discovered from the
+-- assigned value's er_typeid.  Must error, not crash.
+create type bug19382_foo6 as (a int, b int);
+create function bug19382_test_generic_record() returns record as $$
+declare r record;
+begin
+    r := row(123, power(2, 30)::int4)::bug19382_foo6;
+    alter type bug19382_foo6 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_record();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo6" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_generic_record() line 6 at RETURN
+drop function bug19382_test_generic_record();
+drop type bug19382_foo6 cascade;
+-- Case 11: Generic RECORD with nested composite alter
+create type bug19382_inner2 as (x int, y int);
+create type bug19382_outer2 as (a int, b bug19382_inner2);
+create function bug19382_test_generic_nested() returns record as $$
+declare r record;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner2)::bug19382_outer2;
+    alter type bug19382_inner2 alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_nested();
+ERROR:  cannot return record variable "r" after composite type "bug19382_inner2" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_generic_nested() line 6 at RETURN
+drop function bug19382_test_generic_nested();
+drop type bug19382_outer2 cascade;
+drop type bug19382_inner2 cascade;
+-- Case 12: Anonymous rowtype baseline (RECORDOID with no ::foo cast).
+-- Non-versionable rowtypes must not trigger a false positive.
+create function bug19382_test_anon_baseline() returns record as $$
+declare r record;
+begin
+    select 1 as a, 2 as b into r;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_anon_baseline();
+ bug19382_test_anon_baseline 
+-----------------------------
+ (1,2)
+(1 row)
+
+drop function bug19382_test_anon_baseline();
+-- Case 13: RAISE NOTICE with generic RECORD (RECORDOID + reader path).
+create type bug19382_foo7 as (a int, b int);
+create function bug19382_test_generic_raise() returns void as $$
+declare r record;
+begin
+    r := row(1, power(2, 30)::int4)::bug19382_foo7;
+    alter type bug19382_foo7 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_raise();
+ERROR:  cannot return record variable "r" after composite type "bug19382_foo7" was altered
+CONTEXT:  PL/pgSQL function bug19382_test_generic_raise() line 6 at RAISE
+drop function bug19382_test_generic_raise();
+drop type bug19382_foo7 cascade;
diff --git a/src/pl/plpgsql/src/pl_exec.c b/src/pl/plpgsql/src/pl_exec.c
index 4fa32238330..0cb1756760a 100644
--- a/src/pl/plpgsql/src/pl_exec.c
+++ b/src/pl/plpgsql/src/pl_exec.c
@@ -3304,7 +3304,7 @@ exec_stmt_return(PLpgSQL_execstate *estate, PLpgSQL_stmt_return *stmt)
 					 * the record was populated. If so, raise an error to
 					 * prevent crashes when outputting the record.
 					 */
-					if (rec->rectypeid != RECORDOID && rec->erh != NULL &&
+					if (rec->erh != NULL &&
 						!ExpandedRecordIsEmpty(rec->erh))
 						check_record_type_not_altered(rec);
 
@@ -3468,7 +3468,7 @@ exec_stmt_return_next(PLpgSQL_execstate *estate,
 					 * the record was populated. If so, raise an error to
 					 * prevent crashes when storing to the tuplestore.
 					 */
-					if (rec->rectypeid != RECORDOID && rec->erh != NULL)
+					if (rec->erh != NULL && !ExpandedRecordIsEmpty(rec->erh))
 						check_record_type_not_altered(rec);
 
 					/* If rec is null, try to convert it to a row of nulls */
@@ -5493,8 +5493,7 @@ exec_eval_datum(PLpgSQL_execstate *estate,
 					 * the record was populated.  This catches all output
 					 * paths: RETURN, RAISE, EXECUTE USING, etc.
 					 */
-					if (rec->rectypeid != RECORDOID &&
-						!ExpandedRecordIsEmpty(rec->erh))
+					if (!ExpandedRecordIsEmpty(rec->erh))
 						check_record_type_not_altered(rec);
 
 					if (ExpandedRecordIsEmpty(rec->erh))
@@ -9308,8 +9307,17 @@ check_record_type_not_altered(PLpgSQL_rec *rec)
 	Oid			check_typid;
 	int			i;
 
-	/* Nothing to do for anonymous RECORD type */
-	if (rec->rectypeid == RECORDOID)
+	/*
+	 * Determine the effective type to check.  For a variable declared as
+	 * generic RECORD, use the ExpandedRecord's actual type once it's been
+	 * assigned.  If that too is RECORDOID (truly anonymous rowtype), there
+	 * is nothing to check because such rowtypes are not versioned.
+	 */
+	if (rec->rectypeid != RECORDOID)
+		check_typid = rec->rectypeid;
+	else if (rec->erh != NULL && rec->erh->er_typeid != RECORDOID)
+		check_typid = rec->erh->er_typeid;
+	else
 		return;
 
 	/*
@@ -9318,7 +9326,6 @@ check_record_type_not_altered(PLpgSQL_rec *rec)
 	 * because er_tupdesc_id is set when the ExpandedRecord is created.
 	 * Resolve domain types to their base composite type first.
 	 */
-	check_typid = rec->rectypeid;
 	if (get_typtype(check_typid) == TYPTYPE_DOMAIN)
 		check_typid = getBaseType(check_typid);
 
@@ -9432,19 +9439,34 @@ collect_composite_type_versions(Oid typid,
  * reachable from the record's declared type.  Called when a record variable
  * is assigned a new value, so that check_record_type_not_altered() can
  * detect mid-transaction ALTER TYPE at RETURN time.
+ *
+ * For a variable declared as generic RECORD (rectypeid == RECORDOID), the
+ * effective root type is taken from the ExpandedRecord's er_typeid, which
+ * reflects the actual composite type adopted from the assigned value.  If
+ * that too is RECORDOID (truly anonymous rowtype), no snapshot is taken
+ * because such rowtypes are not versioned by the type cache.
  */
 static void
 snapshot_record_composite_types(PLpgSQL_execstate *estate,
 								PLpgSQL_rec *rec)
 {
 	MemoryContext oldcxt;
+	Oid			root_typid;
 	int			alloc = 8;
 	int			n = 0;
 	Oid		   *oids;
 	uint64	   *versions;
 
-	/* Nothing to do for anonymous RECORD type */
-	if (rec->rectypeid == RECORDOID)
+	/*
+	 * Determine the effective root type.  For a variable declared as generic
+	 * RECORD, use the ExpandedRecord's actual type once it's been assigned.
+	 * Truly anonymous rowtypes (er_typeid still RECORDOID) cannot be tracked.
+	 */
+	if (rec->rectypeid != RECORDOID)
+		root_typid = rec->rectypeid;
+	else if (rec->erh != NULL && rec->erh->er_typeid != RECORDOID)
+		root_typid = rec->erh->er_typeid;
+	else
 	{
 		rec->nCompTypes = 0;
 		return;
@@ -9454,7 +9476,7 @@ snapshot_record_composite_types(PLpgSQL_execstate *estate,
 	oids = palloc(alloc * sizeof(Oid));
 	versions = palloc(alloc * sizeof(uint64));
 
-	collect_composite_type_versions(rec->rectypeid,
+	collect_composite_type_versions(root_typid,
 									&oids, &versions, &n, &alloc);
 
 	MemoryContextSwitchTo(oldcxt);
diff --git a/src/pl/plpgsql/src/sql/plpgsql_record.sql b/src/pl/plpgsql/src/sql/plpgsql_record.sql
index 9f33e49118a..f2f9c85f226 100644
--- a/src/pl/plpgsql/src/sql/plpgsql_record.sql
+++ b/src/pl/plpgsql/src/sql/plpgsql_record.sql
@@ -717,3 +717,61 @@ select bug19382_test_reassign();
 drop function bug19382_test_reassign();
 drop type bug19382_outer3 cascade;
 drop type bug19382_inner3 cascade;
+
+-- Case 10: Generic RECORD variable assigned via ROW::foo cast
+-- The declared type is RECORDOID; effective type is discovered from the
+-- assigned value's er_typeid.  Must error, not crash.
+create type bug19382_foo6 as (a int, b int);
+create function bug19382_test_generic_record() returns record as $$
+declare r record;
+begin
+    r := row(123, power(2, 30)::int4)::bug19382_foo6;
+    alter type bug19382_foo6 alter attribute b type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_record();
+drop function bug19382_test_generic_record();
+drop type bug19382_foo6 cascade;
+
+-- Case 11: Generic RECORD with nested composite alter
+create type bug19382_inner2 as (x int, y int);
+create type bug19382_outer2 as (a int, b bug19382_inner2);
+create function bug19382_test_generic_nested() returns record as $$
+declare r record;
+begin
+    r := row(1, row(10, power(2, 30)::int4)::bug19382_inner2)::bug19382_outer2;
+    alter type bug19382_inner2 alter attribute y type text;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_nested();
+drop function bug19382_test_generic_nested();
+drop type bug19382_outer2 cascade;
+drop type bug19382_inner2 cascade;
+
+-- Case 12: Anonymous rowtype baseline (RECORDOID with no ::foo cast).
+-- Non-versionable rowtypes must not trigger a false positive.
+create function bug19382_test_anon_baseline() returns record as $$
+declare r record;
+begin
+    select 1 as a, 2 as b into r;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_anon_baseline();
+drop function bug19382_test_anon_baseline();
+
+-- Case 13: RAISE NOTICE with generic RECORD (RECORDOID + reader path).
+create type bug19382_foo7 as (a int, b int);
+create function bug19382_test_generic_raise() returns void as $$
+declare r record;
+begin
+    r := row(1, power(2, 30)::int4)::bug19382_foo7;
+    alter type bug19382_foo7 alter attribute b type text;
+    raise notice 'r = %', r;
+end;
+$$ language plpgsql;
+select bug19382_test_generic_raise();
+drop function bug19382_test_generic_raise();
+drop type bug19382_foo7 cascade;
-- 
2.39.5 (Apple Git-154)

