From be7aa5410fc92c66744e83aac383e6918edc1852 Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Sat, 24 Jan 2026 08:19:54 +0800
Subject: [PATCH v3 2/2] ALTER TABLE: recurse REPLICA IDENTITY to partitions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ALTER TABLE ... REPLICA IDENTITY did not recurse to child partitions when
applied to a partitioned table.

Teach ALTER TABLE REPLICA IDENTITY to recurse to partitions unless ONLY
is specified. For non-index replica identities, recursion uses the
standard ALTER TABLE recursion mechanism. For index-based replica
identity, recursion is performed level by level so that each partition’s
replica identity is set using the corresponding partition index derived
from its direct parent index.

Add regression tests covering multi-level partitioning, cross-schema
partitions, ONLY behavior, and index rebuild interactions.

Author: Chao Li <lic@highgo.com>
Discussion: https://postgr.es/m/CAEoWx2nJ71hy8R614HQr7vQhkBReO9AANPODPg0aSQs74eOdLQ@mail.gmail.com
---
 src/backend/commands/tablecmds.c              | 82 +++++++++++++++++--
 .../regress/expected/replica_identity.out     | 69 ++++++++++++++++
 src/test/regress/sql/replica_identity.sql     | 40 +++++++++
 3 files changed, 184 insertions(+), 7 deletions(-)

diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f976c0e5c7e..a8e4e7a65ac 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -693,7 +693,9 @@ static void drop_parent_dependency(Oid relid, Oid refclassid, Oid refobjid,
 								   DependencyType deptype);
 static ObjectAddress ATExecAddOf(Relation rel, const TypeName *ofTypename, LOCKMODE lockmode);
 static void ATExecDropOf(Relation rel, LOCKMODE lockmode);
-static void ATExecReplicaIdentity(Relation rel, ReplicaIdentityStmt *stmt, LOCKMODE lockmode);
+static void ATPrepReplicaIdentity(List **wqueue, Relation rel, AlterTableCmd *cmd, bool recurse,
+								  LOCKMODE lockmode, AlterTableUtilityContext *context);
+static void ATExecReplicaIdentity(Relation rel, ReplicaIdentityStmt *stmt, bool recurse, LOCKMODE lockmode);
 static void ATExecGenericOptions(Relation rel, List *options);
 static void ATExecSetRowSecurity(Relation rel, bool rls);
 static void ATExecForceNoForceRowSecurity(Relation rel, bool force_rls);
@@ -5226,9 +5228,9 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd,
 		case AT_ReplicaIdentity:	/* REPLICA IDENTITY ... */
 			ATSimplePermissions(cmd->subtype, rel,
 								ATT_TABLE | ATT_PARTITIONED_TABLE | ATT_MATVIEW);
+			/* Performs own recursion */
+			ATPrepReplicaIdentity(wqueue, rel, cmd, recurse, lockmode, context);
 			pass = AT_PASS_MISC;
-			/* This command never recurses */
-			/* No command-specific prep needed */
 			break;
 		case AT_EnableTrig:		/* ENABLE TRIGGER variants */
 		case AT_EnableAlwaysTrig:
@@ -5643,7 +5645,8 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab,
 			ATExecDropOf(rel, lockmode);
 			break;
 		case AT_ReplicaIdentity:
-			ATExecReplicaIdentity(rel, (ReplicaIdentityStmt *) cmd->def, lockmode);
+			ATExecReplicaIdentity(rel, (ReplicaIdentityStmt *) cmd->def,
+								  cmd->recurse, lockmode);
 			break;
 		case AT_EnableRowSecurity:
 			ATExecSetRowSecurity(rel, true);
@@ -18511,15 +18514,80 @@ relation_mark_replica_identity(Relation rel, char ri_type, Oid indexOid,
 	table_close(pg_index, RowExclusiveLock);
 }
 
+/*
+ * Prepare to set replica identity. Recurse to child partitions if we are asked for.
+ */
+static void
+ATPrepReplicaIdentity(List **wqueue, Relation rel, AlterTableCmd *cmd, bool recurse,
+					  LOCKMODE lockmode, AlterTableUtilityContext *context)
+{
+	ReplicaIdentityStmt *stmt = (ReplicaIdentityStmt *) (cmd->def);
+	List	   *partRelIds = NIL;
+	Oid			indexId;
+
+	if (!recurse || rel->rd_rel->relkind != RELKIND_PARTITIONED_TABLE)
+		return;
+
+	/* Fast path for non-index replica identity. */
+	if (stmt->identity_type != REPLICA_IDENTITY_INDEX)
+	{
+		ATSimpleRecursion(wqueue, rel, cmd, recurse, lockmode, context);
+		return;
+	}
+
+	/*
+	 * For index replica identity, the key problem is to find the
+	 * corresponding index on each partition. To determine a partition’s
+	 * index name, we need the index OID of its direct parent, so we
+	 * intentionally find the direct children, then recurse down to next
+	 * level.
+	 */
+
+	indexId = get_relname_relid(stmt->name, rel->rd_rel->relnamespace);
+	partRelIds = find_inheritance_children(RelationGetRelid(rel), lockmode);
+	foreach_oid(partRelOid, partRelIds)
+	{
+		Relation	partRel;
+		Oid			partIndexId;
+		char	   *partIndexName;
+		AlterTableCmd *cmdCopy;
+
+		if (partRelOid == RelationGetRelid(rel))
+			continue;
+
+		partRel = relation_open(partRelOid, lockmode);
+
+		/*
+		 * Find the partition's corresponding index. This should be unlikely
+		 * to fail because when creating an index on a partitioned table, the
+		 * index will be automatically created on all partitions, and it's
+		 * not allowed to drop the index from individual partition.
+		 */
+		partIndexId = index_get_partition(partRel, indexId);
+		if (!OidIsValid(partIndexId))
+			ereport(ERROR,
+					errcode(ERRCODE_UNDEFINED_OBJECT),
+					errmsg("cannot find index \"%s\" for partition \"%s\"",
+						   stmt->name, RelationGetRelationName(partRel)));
+
+		cmdCopy = copyObject(cmd);
+		partIndexName = get_rel_name(partIndexId);
+		((ReplicaIdentityStmt *) (cmdCopy->def))->name = partIndexName;
+		CheckAlterTableIsSafe(partRel);
+		ATPrepCmd(wqueue, partRel, cmdCopy, recurse, true, lockmode, context);
+
+		relation_close(partRel, NoLock);
+	}
+}
+
 /*
  * ALTER TABLE <name> REPLICA IDENTITY ...
  */
 static void
-ATExecReplicaIdentity(Relation rel, ReplicaIdentityStmt *stmt, LOCKMODE lockmode)
+ATExecReplicaIdentity(Relation rel, ReplicaIdentityStmt *stmt, bool recurse, LOCKMODE lockmode)
 {
 	Oid			indexOid;
 	Relation	indexRel;
-	int			key;
 
 	if (stmt->identity_type == REPLICA_IDENTITY_DEFAULT)
 	{
@@ -18594,7 +18662,7 @@ ATExecReplicaIdentity(Relation rel, ReplicaIdentityStmt *stmt, LOCKMODE lockmode
 						RelationGetRelationName(indexRel))));
 
 	/* Check index for nullable columns. */
-	for (key = 0; key < IndexRelationGetNumberOfKeyAttributes(indexRel); key++)
+	for (int key = 0; key < IndexRelationGetNumberOfKeyAttributes(indexRel); key++)
 	{
 		int16		attno = indexRel->rd_index->indkey.values[key];
 		Form_pg_attribute attr;
diff --git a/src/test/regress/expected/replica_identity.out b/src/test/regress/expected/replica_identity.out
index 9766bd5af89..7b80d12c600 100644
--- a/src/test/regress/expected/replica_identity.out
+++ b/src/test/regress/expected/replica_identity.out
@@ -357,6 +357,73 @@ SELECT relreplident FROM pg_class
  f
 (1 row)
 
+----
+-- Test recurse to child partitions
+----
+CREATE TABLE test_replica_identity8 (a int not null, b int not null, c int not null) PARTITION BY LIST (a);
+CREATE TABLE test_replica_identity8_p1 PARTITION OF test_replica_identity8
+  FOR VALUES IN (1) PARTITION BY LIST(b);
+CREATE TABLE test_replica_identity8_p1_leaf1 PARTITION OF test_replica_identity8_p1
+  FOR VALUES IN (1);
+CREATE TABLE test_replica_identity8_p1_leaf2 PARTITION OF test_replica_identity8_p1
+  FOR VALUES IN (2);
+-- For better coverage, create a partition in the other schema
+CREATE SCHEMA test_replica_identity_schema8;
+CREATE TABLE test_replica_identity_schema8.test_replica_identity8_p1 PARTITION OF test_replica_identity8
+  FOR VALUES IN (2);
+-- With ONLY, it should only update the current table
+ALTER TABLE ONLY test_replica_identity8 REPLICA IDENTITY FULL;
+SELECT relname, relreplident FROM pg_class WHERE relname LIKE 'test_replica_identity8%' ORDER BY relname;
+             relname             | relreplident 
+---------------------------------+--------------
+ test_replica_identity8          | f
+ test_replica_identity8_p1       | d
+ test_replica_identity8_p1       | d
+ test_replica_identity8_p1_leaf1 | d
+ test_replica_identity8_p1_leaf2 | d
+(5 rows)
+
+ALTER TABLE test_replica_identity8 REPLICA IDENTITY NOTHING;
+SELECT relname, relreplident FROM pg_class WHERE relname LIKE 'test_replica_identity8%' ORDER BY relname;
+             relname             | relreplident 
+---------------------------------+--------------
+ test_replica_identity8          | n
+ test_replica_identity8_p1       | n
+ test_replica_identity8_p1       | n
+ test_replica_identity8_p1_leaf1 | n
+ test_replica_identity8_p1_leaf2 | n
+(5 rows)
+
+-- Without ONLY, it should update all partitions as well
+CREATE UNIQUE INDEX test_idx_replica_identity8 ON test_replica_identity8 (a, b, c);
+ALTER TABLE test_replica_identity8 REPLICA IDENTITY USING INDEX test_idx_replica_identity8;
+SELECT c.relname, c.relreplident, i.indexrelid::regclass AS index_name FROM pg_class c
+  LEFT JOIN pg_index i ON c.oid = i.indrelid
+  WHERE relname LIKE 'test_replica_identity8%' AND i.indisreplident = true
+  ORDER BY c.relname;
+             relname             | relreplident |                            index_name                             
+---------------------------------+--------------+-------------------------------------------------------------------
+ test_replica_identity8          | i            | test_idx_replica_identity8
+ test_replica_identity8_p1       | i            | test_replica_identity8_p1_a_b_c_idx
+ test_replica_identity8_p1       | i            | test_replica_identity_schema8.test_replica_identity8_p1_a_b_c_idx
+ test_replica_identity8_p1_leaf1 | i            | test_replica_identity8_p1_leaf1_a_b_c_idx
+ test_replica_identity8_p1_leaf2 | i            | test_replica_identity8_p1_leaf2_a_b_c_idx
+(5 rows)
+
+-- After index rebuild, index replica identity should be updated
+ALTER TABLE test_replica_identity8 ALTER c TYPE bigint;
+SELECT c.relname, c.relreplident, i.indexrelid::regclass AS index_name FROM pg_class c
+  LEFT JOIN pg_index i ON c.oid = i.indrelid
+  WHERE relname LIKE 'test_replica_identity8%' AND i.indisreplident = true
+  ORDER BY c.relname;
+        relname         | relreplident |         index_name         
+------------------------+--------------+----------------------------
+ test_replica_identity8 | i            | test_idx_replica_identity8
+(1 row)
+
+----
+-- Clean up
+----
 DROP TABLE test_replica_identity;
 DROP TABLE test_replica_identity2;
 DROP TABLE test_replica_identity3;
@@ -366,3 +433,5 @@ DROP TABLE test_replica_identity_othertable;
 DROP TABLE test_replica_identity_t3;
 DROP TABLE test_replica_identity6;
 DROP TABLE test_replica_identity7;
+DROP TABLE test_replica_identity8;
+DROP SCHEMA test_replica_identity_schema8;
diff --git a/src/test/regress/sql/replica_identity.sql b/src/test/regress/sql/replica_identity.sql
index 7c0c7365c26..4469de69592 100644
--- a/src/test/regress/sql/replica_identity.sql
+++ b/src/test/regress/sql/replica_identity.sql
@@ -173,6 +173,44 @@ ALTER TABLE test_replica_identity7_p ATTACH PARTITION test_replica_identity7_lea
 SELECT relreplident FROM pg_class
   WHERE oid = 'test_replica_identity7_leaf'::regclass;
 
+----
+-- Test recurse to child partitions
+----
+
+CREATE TABLE test_replica_identity8 (a int not null, b int not null, c int not null) PARTITION BY LIST (a);
+CREATE TABLE test_replica_identity8_p1 PARTITION OF test_replica_identity8
+  FOR VALUES IN (1) PARTITION BY LIST(b);
+CREATE TABLE test_replica_identity8_p1_leaf1 PARTITION OF test_replica_identity8_p1
+  FOR VALUES IN (1);
+CREATE TABLE test_replica_identity8_p1_leaf2 PARTITION OF test_replica_identity8_p1
+  FOR VALUES IN (2);
+-- For better coverage, create a partition in the other schema
+CREATE SCHEMA test_replica_identity_schema8;
+CREATE TABLE test_replica_identity_schema8.test_replica_identity8_p1 PARTITION OF test_replica_identity8
+  FOR VALUES IN (2);
+-- With ONLY, it should only update the current table
+ALTER TABLE ONLY test_replica_identity8 REPLICA IDENTITY FULL;
+SELECT relname, relreplident FROM pg_class WHERE relname LIKE 'test_replica_identity8%' ORDER BY relname;
+ALTER TABLE test_replica_identity8 REPLICA IDENTITY NOTHING;
+SELECT relname, relreplident FROM pg_class WHERE relname LIKE 'test_replica_identity8%' ORDER BY relname;
+-- Without ONLY, it should update all partitions as well
+CREATE UNIQUE INDEX test_idx_replica_identity8 ON test_replica_identity8 (a, b, c);
+ALTER TABLE test_replica_identity8 REPLICA IDENTITY USING INDEX test_idx_replica_identity8;
+SELECT c.relname, c.relreplident, i.indexrelid::regclass AS index_name FROM pg_class c
+  LEFT JOIN pg_index i ON c.oid = i.indrelid
+  WHERE relname LIKE 'test_replica_identity8%' AND i.indisreplident = true
+  ORDER BY c.relname;
+-- After index rebuild, index replica identity should be updated
+ALTER TABLE test_replica_identity8 ALTER c TYPE bigint;
+SELECT c.relname, c.relreplident, i.indexrelid::regclass AS index_name FROM pg_class c
+  LEFT JOIN pg_index i ON c.oid = i.indrelid
+  WHERE relname LIKE 'test_replica_identity8%' AND i.indisreplident = true
+  ORDER BY c.relname;
+
+----
+-- Clean up
+----
+
 DROP TABLE test_replica_identity;
 DROP TABLE test_replica_identity2;
 DROP TABLE test_replica_identity3;
@@ -182,3 +220,5 @@ DROP TABLE test_replica_identity_othertable;
 DROP TABLE test_replica_identity_t3;
 DROP TABLE test_replica_identity6;
 DROP TABLE test_replica_identity7;
+DROP TABLE test_replica_identity8;
+DROP SCHEMA test_replica_identity_schema8;
-- 
2.50.1 (Apple Git-155)

