From 806e68f1967555f5f7f6f12542f78c05b5665410 Mon Sep 17 00:00:00 2001
From: Nikhil Sontakke <nikhil@planetscale.com>
Date: Thu, 13 Aug 2026 15:08:26 +0530
Subject: [PATCH v2] Refuse to drop a column whose row type is stored in
 another relation

A composite datum records only the OID and typmod of its row type, not
the shape it was built with, so stored values are read back against the
current definition.  Dropping an attribute therefore reaches backwards:
it vanishes from values written before the drop, and values that were
distinct become equal.  Under a unique index that leaves the table
holding data the server still rejects in new rows and the index can no
longer be rebuilt from: REINDEX fails, and so does restoring the
database from its own dump.

ALTER COLUMN TYPE already refuses to retype a row type that another
relation stores, through find_composite_type_dependencies(), but the
call is gated on a rewrite being queued and the drop path queues none.
Call it from ATExecDropColumn() as well.  This covers ALTER TABLE ...
DROP COLUMN and ALTER TYPE ... DROP ATTRIBUTE alike, since a table's row
type is a composite type too, and it is done per level so that
inheritance children are covered.

Three existing tests depended on the old behaviour.  test_typex dropped
an attribute of a stored type; test_tbl3 built a pg_upgrade fixture that
way and now drops the attribute before creating the table; and the
test_decoding isolation test oldest_xmin used the drop only as a DDL
that updates a pg_attribute tuple a concurrent reader has already seen,
which RENAME ATTRIBUTE does equally well.
---
 .../test_decoding/expected/oldest_xmin.out    |  4 +-
 contrib/test_decoding/specs/oldest_xmin.spec  |  5 +-
 src/backend/commands/tablecmds.c              | 12 +++++
 src/test/regress/expected/alter_table.out     | 53 ++++++++++++++++---
 src/test/regress/sql/alter_table.sql          | 40 ++++++++++++--
 5 files changed, 101 insertions(+), 13 deletions(-)

diff --git a/contrib/test_decoding/expected/oldest_xmin.out b/contrib/test_decoding/expected/oldest_xmin.out
index 57268b38d33..70f3b258287 100644
--- a/contrib/test_decoding/expected/oldest_xmin.out
+++ b/contrib/test_decoding/expected/oldest_xmin.out
@@ -10,7 +10,7 @@ f
 
 step s1_begin: BEGIN;
 step s1_insert: INSERT INTO harvest VALUES ((1, 2, 3));
-step s0_alter: ALTER TYPE basket DROP ATTRIBUTE mangos;
+step s0_alter: ALTER TYPE basket RENAME ATTRIBUTE mangos TO plums;
 step s0_commit: COMMIT;
 step s0_checkpoint: CHECKPOINT;
 step s0_get_changes: SELECT data FROM pg_logical_slot_get_changes('isolation_slot', NULL, NULL, 'include-xids', '0', 'skip-empty-xacts', '1');
@@ -49,7 +49,7 @@ f
 
 step s1_begin: BEGIN;
 step s1_insert: INSERT INTO harvest VALUES ((1, 2, 3));
-step s0_alter: ALTER TYPE basket DROP ATTRIBUTE mangos;
+step s0_alter: ALTER TYPE basket RENAME ATTRIBUTE mangos TO plums;
 step s0_commit: COMMIT;
 step s0_checkpoint: CHECKPOINT;
 step s0_advance_slot: SELECT slot_name FROM pg_replication_slot_advance('isolation_slot', pg_current_wal_lsn());
diff --git a/contrib/test_decoding/specs/oldest_xmin.spec b/contrib/test_decoding/specs/oldest_xmin.spec
index 7f2fe3d7ed7..97245f3d452 100644
--- a/contrib/test_decoding/specs/oldest_xmin.spec
+++ b/contrib/test_decoding/specs/oldest_xmin.spec
@@ -20,7 +20,10 @@ session "s0"
 setup { SET synchronous_commit=on; }
 step "s0_begin" { BEGIN; }
 step "s0_getxid" { SELECT pg_current_xact_id() IS NULL; }
-step "s0_alter" { ALTER TYPE basket DROP ATTRIBUTE mangos; }
+# Any DDL that updates a pg_attribute tuple s1 has already read will do here.
+# Renaming rather than dropping, because dropping an attribute of a type that
+# a table stores is refused.
+step "s0_alter" { ALTER TYPE basket RENAME ATTRIBUTE mangos TO plums; }
 step "s0_commit" { COMMIT; }
 step "s0_checkpoint" { CHECKPOINT; }
 step "s0_vacuum" { VACUUM pg_attribute; }
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 12beb37246f..14d01b6458a 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -9504,6 +9504,18 @@ ATExecDropColumn(List **wqueue, Relation rel, const char *colName,
 				 errmsg("cannot drop column \"%s\" because it is part of the partition key of relation \"%s\"",
 						colName, RelationGetRelationName(rel))));
 
+	/*
+	 * Don't drop it if some other relation stores values of this relation's
+	 * row type.  A composite datum records only the OID and typmod of its row
+	 * type, never the shape it was built with, so every stored value is read
+	 * back against the current definition.  Dropping the column would
+	 * therefore make the attribute disappear from values that were written
+	 * before the drop: values that were distinct can become equal, which can
+	 * leave stored data violating a unique index that can then no longer be
+	 * rebuilt.  ALTER COLUMN TYPE already refuses for the same reason.
+	 */
+	find_composite_type_dependencies(rel->rd_rel->reltype, rel, NULL);
+
 	ReleaseSysCache(tuple);
 
 	/*
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..c542f5ce012 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -3323,29 +3323,68 @@ Inherits:
 
 DROP TABLE test_tbl2_subclass, test_tbl2;
 DROP TYPE test_type2;
+-- Dropping an attribute is refused once a relation with storage has a column
+-- of the type, whether or not anything else depends on the attribute, and
+-- CASCADE does not override it.
 CREATE TYPE test_typex AS (a int, b text);
 CREATE TABLE test_tblx (x int, y test_typex check ((y).a > 0));
 ALTER TYPE test_typex DROP ATTRIBUTE a; -- fails
-ERROR:  cannot drop column a of composite type test_typex because other objects depend on it
-DETAIL:  constraint test_tblx_y_check on table test_tblx depends on column a of composite type test_typex
-HINT:  Use DROP ... CASCADE to drop the dependent objects too.
-ALTER TYPE test_typex DROP ATTRIBUTE a CASCADE;
-NOTICE:  drop cascades to constraint test_tblx_y_check on table test_tblx
+ERROR:  cannot alter type "test_typex" because column "test_tblx.y" uses it
+ALTER TYPE test_typex DROP ATTRIBUTE a CASCADE; -- fails
+ERROR:  cannot alter type "test_typex" because column "test_tblx.y" uses it
 \d test_tblx
                Table "public.test_tblx"
  Column |    Type    | Collation | Nullable | Default 
 --------+------------+-----------+----------+---------
  x      | integer    |           |          | 
  y      | test_typex |           |          | 
+Check constraints:
+    "test_tblx_y_check" CHECK ((y).a > 0)
 
 DROP TABLE test_tblx;
 DROP TYPE test_typex;
+-- Values of a composite type record only its OID and typmod, never the shape
+-- they were built with, so a stored value is read back against whatever the
+-- type looks like at read time.  Dropping an attribute would therefore make it
+-- disappear from values written before the drop, which is refused: values that
+-- were distinct would become equal, and where the column carries a unique
+-- index the table would be left holding data that violates it and that the
+-- index can no longer be rebuilt from.
+CREATE TYPE test_stored AS (a int, b int);
+CREATE TABLE test_stored_tbl (v test_stored);
+CREATE UNIQUE INDEX test_stored_tbl_v ON test_stored_tbl (v);
+INSERT INTO test_stored_tbl VALUES (ROW(1, 2)::test_stored),
+                                   (ROW(1, 3)::test_stored);
+ALTER TYPE test_stored DROP ATTRIBUTE b; -- fails
+ERROR:  cannot alter type "test_stored" because column "test_stored_tbl.v" uses it
+-- still two distinct values, and the index still rebuilds from them
+SELECT count(DISTINCT v) FROM test_stored_tbl;
+ count 
+-------
+     2
+(1 row)
+
+REINDEX INDEX test_stored_tbl_v;
+-- once nothing stores the type, dropping the attribute is allowed again
+DROP TABLE test_stored_tbl;
+ALTER TYPE test_stored DROP ATTRIBUTE b;
+DROP TYPE test_stored;
+-- The same applies to a table's row type, which is a composite type too.
+CREATE TABLE test_rowtype (a int, b int);
+CREATE TABLE test_rowtype_ref (v test_rowtype);
+ALTER TABLE test_rowtype DROP COLUMN b; -- fails
+ERROR:  cannot alter table "test_rowtype" because column "test_rowtype_ref.v" uses its row type
+DROP TABLE test_rowtype_ref;
+ALTER TABLE test_rowtype DROP COLUMN b;
+DROP TABLE test_rowtype;
 -- This test isn't that interesting on its own, but the purpose is to leave
 -- behind a table to test pg_upgrade with. The table has a composite type
--- column in it, and the composite type has a dropped attribute.
+-- column in it, and the composite type has a dropped attribute.  The attribute
+-- is dropped before the table is created, since it could not be dropped
+-- afterwards.
 CREATE TYPE test_type3 AS (a int);
-CREATE TABLE test_tbl3 (c) AS SELECT '(1)'::test_type3;
 ALTER TYPE test_type3 DROP ATTRIBUTE a, ADD ATTRIBUTE b int;
+CREATE TABLE test_tbl3 (c) AS SELECT '(1)'::test_type3;
 CREATE TYPE test_type_empty AS ();
 DROP TYPE test_type_empty;
 --
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..dff561a1a0a 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -2039,20 +2039,54 @@ ALTER TYPE test_type2 RENAME ATTRIBUTE a TO aa CASCADE;
 DROP TABLE test_tbl2_subclass, test_tbl2;
 DROP TYPE test_type2;
 
+-- Dropping an attribute is refused once a relation with storage has a column
+-- of the type, whether or not anything else depends on the attribute, and
+-- CASCADE does not override it.
 CREATE TYPE test_typex AS (a int, b text);
 CREATE TABLE test_tblx (x int, y test_typex check ((y).a > 0));
 ALTER TYPE test_typex DROP ATTRIBUTE a; -- fails
-ALTER TYPE test_typex DROP ATTRIBUTE a CASCADE;
+ALTER TYPE test_typex DROP ATTRIBUTE a CASCADE; -- fails
 \d test_tblx
 DROP TABLE test_tblx;
 DROP TYPE test_typex;
 
+-- Values of a composite type record only its OID and typmod, never the shape
+-- they were built with, so a stored value is read back against whatever the
+-- type looks like at read time.  Dropping an attribute would therefore make it
+-- disappear from values written before the drop, which is refused: values that
+-- were distinct would become equal, and where the column carries a unique
+-- index the table would be left holding data that violates it and that the
+-- index can no longer be rebuilt from.
+CREATE TYPE test_stored AS (a int, b int);
+CREATE TABLE test_stored_tbl (v test_stored);
+CREATE UNIQUE INDEX test_stored_tbl_v ON test_stored_tbl (v);
+INSERT INTO test_stored_tbl VALUES (ROW(1, 2)::test_stored),
+                                   (ROW(1, 3)::test_stored);
+ALTER TYPE test_stored DROP ATTRIBUTE b; -- fails
+-- still two distinct values, and the index still rebuilds from them
+SELECT count(DISTINCT v) FROM test_stored_tbl;
+REINDEX INDEX test_stored_tbl_v;
+-- once nothing stores the type, dropping the attribute is allowed again
+DROP TABLE test_stored_tbl;
+ALTER TYPE test_stored DROP ATTRIBUTE b;
+DROP TYPE test_stored;
+
+-- The same applies to a table's row type, which is a composite type too.
+CREATE TABLE test_rowtype (a int, b int);
+CREATE TABLE test_rowtype_ref (v test_rowtype);
+ALTER TABLE test_rowtype DROP COLUMN b; -- fails
+DROP TABLE test_rowtype_ref;
+ALTER TABLE test_rowtype DROP COLUMN b;
+DROP TABLE test_rowtype;
+
 -- This test isn't that interesting on its own, but the purpose is to leave
 -- behind a table to test pg_upgrade with. The table has a composite type
--- column in it, and the composite type has a dropped attribute.
+-- column in it, and the composite type has a dropped attribute.  The attribute
+-- is dropped before the table is created, since it could not be dropped
+-- afterwards.
 CREATE TYPE test_type3 AS (a int);
-CREATE TABLE test_tbl3 (c) AS SELECT '(1)'::test_type3;
 ALTER TYPE test_type3 DROP ATTRIBUTE a, ADD ATTRIBUTE b int;
+CREATE TABLE test_tbl3 (c) AS SELECT '(1)'::test_type3;
 
 CREATE TYPE test_type_empty AS ();
 DROP TYPE test_type_empty;
-- 
2.50.1 (Apple Git-155)

