From e0dce474bcdeb0155abe322b7f7d2e9b4ba65635 Mon Sep 17 00:00:00 2001
From: Surya Poondla <suryapoondla4@gmail.com>
Date: Mon, 13 Jul 2026 13:22:54 -0700
Subject: [PATCH v12 2/4] Refresh composite-type state on in-place record
 reassignment (bug #19382)

The previous commit added a snapshot of reachable composite types on
whole-record assignment (via assign_record_var).  However, when the LHS
record variable already has an ExpandedRecord and the RHS has a
matching rowtype, exec_move_row_from_datum takes a fast path that
overwrites the tuple bytes in place via expanded_record_set_tuple and
returns without going through assign_record_var.  On that path both the
composite-type snapshot and the ExpandedRecord's outer-type identifier
(er_tupdesc_id) were left stale.

Two symptoms result, both reported by Zsolt Parragi.

1. False positive on inner-type ALTER + reassign

    r out2;
    r := ROW(1, ROW(10, 20)::inn2)::out2;
    ALTER TYPE inn2 ALTER ATTRIBUTE y TYPE text;
    r := ROW(1, ROW(10, 'hello')::inn2)::out2;   -- fresh valid data
    RETURN r;                                     -- errored under prior patch

   At RETURN the check compared the stale inn2 snapshot against the
   current type-cache version, saw drift, and raised.

2. False positive on outer-type ALTER + reassign

    r foo3;
    r := ROW(1, 2)::foo3;
    ALTER TYPE foo3 ALTER ATTRIBUTE b TYPE text;
    r := ROW(1, 'hello')::foo3;                   -- fresh valid data
    RETURN r;                                     -- master returns it;
                                                  -- prior patch errored

   At RETURN the outer-type check compared rec->erh->er_tupdesc_id (set
   when the ER was created, hence stale) against the current type-cache
   version and raised, even though the freshly-installed bytes match the
   current type.

Add snapshot_record_composite_types() calls at the two in-place set_tuple
paths in exec_move_row_from_datum so any whole-record write refreshes
the nested snapshot.  In addition, refresh_erh_tupdesc_id() re-fetches
the outer-type identifier from the type cache at the same points so the
outer-type check compares current-vs-current.  Field-level writes do not
call this refresh, so partially-stale records are still caught by the
outer check.
---
 .../plpgsql/src/expected/plpgsql_record.out   | 45 ++++++++++++++++++
 src/pl/plpgsql/src/pl_exec.c                  | 46 +++++++++++++++++++
 src/pl/plpgsql/src/sql/plpgsql_record.sql     | 37 +++++++++++++++
 3 files changed, 128 insertions(+)

diff --git a/src/pl/plpgsql/src/expected/plpgsql_record.out b/src/pl/plpgsql/src/expected/plpgsql_record.out
index 821aa4df90b..11bb2ae50f6 100644
--- a/src/pl/plpgsql/src/expected/plpgsql_record.out
+++ b/src/pl/plpgsql/src/expected/plpgsql_record.out
@@ -1017,3 +1017,48 @@ CONTEXT:  PL/pgSQL function bug19382_test_var_copy() line 6 at assignment
 drop function bug19382_test_var_copy();
 drop type bug19382_outer4 cascade;
 drop type bug19382_inner4 cascade;
+-- Case 9: Whole-record reassignment after inner-type alter.
+-- The second assignment builds fresh data matching the post-ALTER type;
+-- the snapshot must refresh so the reassignment succeeds.
+create type bug19382_inner3 as (x int, y int);
+create type bug19382_outer3 as (a int, b bug19382_inner3);
+create function bug19382_test_reassign() returns bug19382_outer3 as $$
+declare r bug19382_outer3;
+begin
+    r := row(1, row(10, 20)::bug19382_inner3)::bug19382_outer3;
+    alter type bug19382_inner3 alter attribute y type text;
+    r := row(1, row(10, 'hello')::bug19382_inner3)::bug19382_outer3;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_reassign();
+ bug19382_test_reassign 
+------------------------
+ (1,"(10,hello)")
+(1 row)
+
+drop function bug19382_test_reassign();
+drop type bug19382_outer3 cascade;
+drop type bug19382_inner3 cascade;
+-- Case 9 (outer-type variant): Whole-record reassignment after ALTER TYPE on
+-- the record's own type.  The reassigned bytes match the post-ALTER definition;
+-- the outer-type identifier (er_tupdesc_id) on the ExpandedRecord must be
+-- refreshed so check_record_type_not_altered() does not falsely reject.
+create type bug19382_foo_outer_reassign as (a int, b int);
+create function bug19382_test_outer_reassign() returns bug19382_foo_outer_reassign as $$
+declare r bug19382_foo_outer_reassign;
+begin
+    r := row(1, 2)::bug19382_foo_outer_reassign;
+    alter type bug19382_foo_outer_reassign alter attribute b type text;
+    r := row(1, 'hello')::bug19382_foo_outer_reassign;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_outer_reassign();
+ bug19382_test_outer_reassign 
+------------------------------
+ (1,hello)
+(1 row)
+
+drop function bug19382_test_outer_reassign();
+drop type bug19382_foo_outer_reassign cascade;
diff --git a/src/pl/plpgsql/src/pl_exec.c b/src/pl/plpgsql/src/pl_exec.c
index 70578b87c7b..46f03d08ef2 100644
--- a/src/pl/plpgsql/src/pl_exec.c
+++ b/src/pl/plpgsql/src/pl_exec.c
@@ -476,6 +476,7 @@ static void collect_composite_type_versions(Oid typid,
 											int *n, int *alloc);
 static void snapshot_record_composite_types(PLpgSQL_execstate *estate,
 											PLpgSQL_rec *rec);
+static void refresh_erh_tupdesc_id(ExpandedRecordHeader *erh);
 
 
 /* ----------
@@ -7799,6 +7800,16 @@ exec_move_row_from_datum(PLpgSQL_execstate *estate,
 			{
 				expanded_record_set_tuple(rec->erh, erh->fvalue,
 										  true, !estate->atomic);
+				/*
+				 * The tuple bytes were replaced in place.  Refresh the
+				 * outer-type identifier and the composite-type snapshot so
+				 * that check_record_type_not_altered() compares against the
+				 * current type cache: a whole-record reassignment installs
+				 * bytes built under the current type, so any residual stale
+				 * versioning from ER creation would produce false positives.
+				 */
+				refresh_erh_tupdesc_id(rec->erh);
+				snapshot_record_composite_types(estate, rec);
 				return;
 			}
 
@@ -7913,6 +7924,9 @@ exec_move_row_from_datum(PLpgSQL_execstate *estate,
 			{
 				expanded_record_set_tuple(rec->erh, &tmptup,
 										  true, !estate->atomic);
+				/* See comment in the analogous branch above. */
+				refresh_erh_tupdesc_id(rec->erh);
+				snapshot_record_composite_types(estate, rec);
 				return;
 			}
 
@@ -9471,3 +9485,35 @@ snapshot_record_composite_types(PLpgSQL_execstate *estate,
 		rec->compTypeVersions = NULL;
 	}
 }
+
+/*
+ * refresh_erh_tupdesc_id
+ *
+ * When an ExpandedRecord's tuple bytes are replaced in place (via
+ * expanded_record_set_tuple) with fresh data built under the current type
+ * definition, its er_tupdesc_id is left at the value assigned when the ER was
+ * created.  That value becomes stale after mid-transaction ALTER TYPE, causing
+ * check_record_type_not_altered() to falsely reject the freshly-installed
+ * bytes as if they still reflected the old definition.
+ *
+ * Refresh er_tupdesc_id from the current type cache so the subsequent outer-
+ * type check compares current-vs-current.  Only whole-record replacement paths
+ * should call this; field-level writes must leave er_tupdesc_id stale so the
+ * check still fires on the unwritten fields that hold pre-ALTER bytes.
+ */
+static void
+refresh_erh_tupdesc_id(ExpandedRecordHeader *erh)
+{
+	Oid			resolved;
+	TypeCacheEntry *typentry;
+
+	if (erh->er_typeid == RECORDOID)
+		return;					/* anonymous rowtype: not versionable */
+
+	resolved = erh->er_typeid;
+	if (get_typtype(resolved) == TYPTYPE_DOMAIN)
+		resolved = getBaseType(resolved);
+
+	typentry = lookup_type_cache(resolved, TYPECACHE_TUPDESC);
+	erh->er_tupdesc_id = typentry->tupDesc_identifier;
+}
diff --git a/src/pl/plpgsql/src/sql/plpgsql_record.sql b/src/pl/plpgsql/src/sql/plpgsql_record.sql
index 0ee7a131eea..4e92af5c845 100644
--- a/src/pl/plpgsql/src/sql/plpgsql_record.sql
+++ b/src/pl/plpgsql/src/sql/plpgsql_record.sql
@@ -698,3 +698,40 @@ select bug19382_test_var_copy();
 drop function bug19382_test_var_copy();
 drop type bug19382_outer4 cascade;
 drop type bug19382_inner4 cascade;
+
+-- Case 9: Whole-record reassignment after inner-type alter.
+-- The second assignment builds fresh data matching the post-ALTER type;
+-- the snapshot must refresh so the reassignment succeeds.
+create type bug19382_inner3 as (x int, y int);
+create type bug19382_outer3 as (a int, b bug19382_inner3);
+create function bug19382_test_reassign() returns bug19382_outer3 as $$
+declare r bug19382_outer3;
+begin
+    r := row(1, row(10, 20)::bug19382_inner3)::bug19382_outer3;
+    alter type bug19382_inner3 alter attribute y type text;
+    r := row(1, row(10, 'hello')::bug19382_inner3)::bug19382_outer3;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_reassign();
+drop function bug19382_test_reassign();
+drop type bug19382_outer3 cascade;
+drop type bug19382_inner3 cascade;
+
+-- Case 9 (outer-type variant): Whole-record reassignment after ALTER TYPE on
+-- the record's own type.  The reassigned bytes match the post-ALTER definition;
+-- the outer-type identifier (er_tupdesc_id) on the ExpandedRecord must be
+-- refreshed so check_record_type_not_altered() does not falsely reject.
+create type bug19382_foo_outer_reassign as (a int, b int);
+create function bug19382_test_outer_reassign() returns bug19382_foo_outer_reassign as $$
+declare r bug19382_foo_outer_reassign;
+begin
+    r := row(1, 2)::bug19382_foo_outer_reassign;
+    alter type bug19382_foo_outer_reassign alter attribute b type text;
+    r := row(1, 'hello')::bug19382_foo_outer_reassign;
+    return r;
+end;
+$$ language plpgsql;
+select bug19382_test_outer_reassign();
+drop function bug19382_test_outer_reassign();
+drop type bug19382_foo_outer_reassign cascade;
-- 
2.39.5 (Apple Git-154)

