From dfbae7f4d4a7812810423c2425a1027c395750d5 Mon Sep 17 00:00:00 2001
From: Nikhil Sontakke <nikhil@planetscale.com>
Date: Thu, 13 Aug 2026 15:08:26 +0530
Subject: [PATCH] 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.  It
carries nothing about the shape it was built with, so every value is read
back against whatever the type looks like at read time.  Dropping an
attribute therefore reaches backwards: it disappears from values that
were written before the drop, and values that were distinct become equal.

Where the column carries a unique index, that leaves the table holding
data which violates a constraint the server still enforces against new
rows, and which the index can no longer be rebuilt from.  REINDEX fails,
and so does restoring the database from its own dump.

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

Includes relevant regression tests, which also demonstrate that CASCADE
does not override the refusal.
---
 src/backend/commands/tablecmds.c          | 12 +++++
 src/test/regress/expected/alter_table.out | 53 ++++++++++++++++++++---
 src/test/regress/sql/alter_table.sql      | 40 +++++++++++++++--
 3 files changed, 95 insertions(+), 10 deletions(-)

diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 8349e724c2b..67e80a56051 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -9505,6 +9505,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)
