From 538617f3e0b7d339b4327cba3e2495cd8bd9e0e5 Mon Sep 17 00:00:00 2001
From: Melanie Plageman <melanieplageman@gmail.com>
Date: Fri, 21 Aug 2026 13:01:21 -0400
Subject: [PATCH v15 2/2] Preserve leaf partition index properties across ALTER
 COLUMN TYPE

When ALTER TABLE ... ALTER COLUMN TYPE or SET EXPRESSION rebuilds a
partitioned index, it drops descendant partition indexes and recreates
them by cloning the parent index definition. Properties not represented
by that cloned definition were lost, including custom names, comments,
constraint comments, replica identity, clustered-index markings,
per-column statistics targets, reloptions, and independently chosen
tablespaces.

Save these properties for each descendant index before the old index
hierarchy is dropped. Propagate the saved properties through DefineIndex()
recursion and restore them on the corresponding replacement index and
constraint.

For a storage-bearing index in the database default tablespace, save an
explicit pg_default tablespace name. This prevents a session
default_tablespace setting from moving the rebuilt index elsewhere.
---
 src/backend/commands/indexcmds.c          | 116 ++++++++++++++++
 src/backend/commands/tablecmds.c          |  91 ++++++++++++
 src/include/nodes/parsenodes.h            |  42 +++++-
 src/test/regress/expected/alter_table.out | 160 ++++++++++++++++++++--
 src/test/regress/sql/alter_table.sql      | 140 +++++++++++++++++++
 src/tools/pgindent/typedefs.list          |   1 +
 6 files changed, 538 insertions(+), 12 deletions(-)

diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index df58ec8e3e8..aa61c342708 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -107,6 +107,8 @@ static char *ChooseIndexName(const char *tabname, Oid namespaceId,
 							 const List *colnames, const List *exclusionOpNames,
 							 bool primary, bool isconstraint);
 static char *ChooseIndexNameAddition(const List *colnames);
+static void TransferPartitionIndexProps(const IndexStmt *stmt, Oid childRelid,
+										IndexStmt *childStmt);
 static List *ChooseIndexColumnNames(Relation rel, const List *indexElems);
 static char *ChooseIndexExpressionName(Relation rel, Node *indexExpr);
 static bool ChooseIndexExpressionName_walker(Node *node,
@@ -555,6 +557,50 @@ SetIndexStatTargets(Oid indexRelationId, List *stattargets)
 }
 
 
+/*
+ * Copy this partition's properties not preserved by the cloned rebuild
+ * definition (name, comment, replica identity, cluster-on, per-column stats
+ * targets, tablespace) from the saved leaf properties into the newly created
+ * IndexStmt for the child partition index as part of an ALTER COLUMN TYPE (or
+ * SET EXPRESSION) operation.
+ *
+ * These were saved in a list before dropping the index in an earlier phase.
+ * After we create the child partition index, we'll update the catalog table
+ * entries according to these values.
+ */
+static void
+TransferPartitionIndexProps(const IndexStmt *stmt, Oid childRelid,
+							IndexStmt *childStmt)
+{
+	foreach_node(PartitionIndexProps, props, stmt->oldPartIndexProps)
+	{
+		/*
+		 * Match entries by the partition table's OID (stable), since the old
+		 * index's OID is gone.
+		 */
+		if (props->partrelid != childRelid)
+			continue;
+
+		childStmt->idxname = pstrdup(props->idxname);
+		childStmt->idxcomment = props->idxcomment;
+		childStmt->idxconstraintcomment = props->constraintcomment;
+		childStmt->idxisreplident = props->isreplident;
+		childStmt->idxisclustered = props->isclustered;
+		childStmt->idxstattargets = props->stattargets;
+
+		/*
+		 * Override the parent's reloptions with the leaf's own, so
+		 * independently-set leaf options survive.
+		 */
+		childStmt->options = props->reloptions;
+		childStmt->tableSpace = props->tableSpace ?
+			pstrdup(props->tableSpace) : NULL;
+		childStmt->reset_default_tblspc = stmt->reset_default_tblspc;
+		return;
+	}
+}
+
+
 /*
  * DefineIndex
  *		Creates a new index.
@@ -1372,10 +1418,69 @@ DefineIndex(ParseState *pstate,
 	if (stmt->idxcomment != NULL)
 		CreateComments(indexRelationId, RelationRelationId, 0,
 					   stmt->idxcomment);
+	if (stmt->idxconstraintcomment != NULL && OidIsValid(createdConstraintId))
+		CreateComments(createdConstraintId, ConstraintRelationId, 0,
+					   stmt->idxconstraintcomment);
 
 	if (stmt->idxstattargets != NIL)
 		SetIndexStatTargets(indexRelationId, stmt->idxstattargets);
 
+	/*
+	 * index_create() has no inputs for indisclustered or indisreplident, so
+	 * restore them with a direct single-row pg_index update.  Don't use
+	 * mark_index_clustered() or relation_mark_replica_identity(), because
+	 * they clear the flag on sibling indexes which are mid-drop here.  That
+	 * is safe to skip because at most one index per table carries each flag
+	 * and it is this newly-created one.  Since this directly alters the new
+	 * index, also invoke the corresponding post-alter hook below.
+	 */
+	if (stmt->idxisclustered || stmt->idxisreplident)
+	{
+		Relation	pg_index;
+		HeapTuple	idxtuple;
+		Form_pg_index indexForm;
+
+		pg_index = table_open(IndexRelationId, RowExclusiveLock);
+		idxtuple = SearchSysCacheCopy1(INDEXRELID,
+									   ObjectIdGetDatum(indexRelationId));
+		if (!HeapTupleIsValid(idxtuple))
+			elog(ERROR, "cache lookup failed for index %u", indexRelationId);
+		indexForm = (Form_pg_index) GETSTRUCT(idxtuple);
+
+		if (stmt->idxisclustered)
+			indexForm->indisclustered = true;
+		if (stmt->idxisreplident)
+			indexForm->indisreplident = true;
+
+		CatalogTupleUpdate(pg_index, &idxtuple->t_self, idxtuple);
+		InvokeObjectPostAlterHookArg(IndexRelationId, indexRelationId, 0,
+									 InvalidOid, !check_rights);
+		heap_freetuple(idxtuple);
+		table_close(pg_index, RowExclusiveLock);
+	}
+
+	/* Replica identity also marks the owning table's relreplident. */
+	if (stmt->idxisreplident)
+	{
+		Relation	pg_class;
+		Oid			heapId = IndexGetRelation(indexRelationId, false);
+		HeapTuple	ctup;
+		Form_pg_class classForm;
+
+		pg_class = table_open(RelationRelationId, RowExclusiveLock);
+		ctup = SearchSysCacheCopy1(RELOID, ObjectIdGetDatum(heapId));
+		if (!HeapTupleIsValid(ctup))
+			elog(ERROR, "cache lookup failed for relation %u", heapId);
+		classForm = (Form_pg_class) GETSTRUCT(ctup);
+		if (classForm->relreplident != REPLICA_IDENTITY_INDEX)
+		{
+			classForm->relreplident = REPLICA_IDENTITY_INDEX;
+			CatalogTupleUpdate(pg_class, &ctup->t_self, ctup);
+		}
+		heap_freetuple(ctup);
+		table_close(pg_class, RowExclusiveLock);
+	}
+
 	if (partitioned)
 	{
 		PartitionDesc partdesc;
@@ -1598,6 +1703,17 @@ DefineIndex(ParseState *pstate,
 														attmap,
 														NULL);
 
+					/*
+					 * Transfer properties not preserved by the cloned rebuild
+					 * definition into the child IndexStmt. The child also
+					 * needs a pointer to the list in case it is an
+					 * intermediate partitioned index and needs its own
+					 * children to be able to find their entries upon
+					 * recursing.
+					 */
+					childStmt->oldPartIndexProps = stmt->oldPartIndexProps;
+					TransferPartitionIndexProps(stmt, childRelid, childStmt);
+
 					/*
 					 * Recurse as the starting user ID.  Callee will use that
 					 * for permission checks, then switch again.
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d8f1158bf79..ede6eadc3a8 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -707,6 +707,7 @@ static void RebuildConstraintComment(AlteredTableInfo *tab, AlterTablePass pass,
 									 Oid objid, Relation rel, List *domname,
 									 const char *conname);
 static void TryReuseIndex(Oid oldId, IndexStmt *stmt);
+static void RememberPartitionIndexProps(Oid indoid, IndexStmt *stmt);
 static List *GetIndexStatTargets(Oid indexOid);
 static void TryReuseForeignKey(Oid oldId, Constraint *con);
 static ObjectAddress ATExecAlterColumnGenericOptions(Relation rel, const char *colName,
@@ -15981,6 +15982,13 @@ RememberWholeRowDependentForRebuilding(AlteredTableInfo *tab, AlterTableType sub
 /*
  * Subroutine for ATExecAlterColumnType: remember that a replica identity
  * needs to be reset.
+ *
+ * We save the index by name and restore it later via an AT_ReplicaIdentity
+ * subcommand (see ATPostAlterTypeCleanup), rather than stamping
+ * indisreplident at creation like the partition-leaf path. The top-level
+ * index may not be rebuilt at all, or may have live siblings whose flag must
+ * be cleared, so it needs the relation-wide relation_mark_replica_identity()
+ * run after the rebuild.
  */
 static void
 RememberReplicaIdentityForRebuilding(Oid indoid, AlteredTableInfo *tab)
@@ -16435,6 +16443,11 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, Oid ownerId,
 			IndexStmt  *stmt = (IndexStmt *) stm;
 			AlterTableCmd *newcmd;
 
+			/*
+			 * capture properties not preserved by the cloned rebuild
+			 * definition
+			 */
+			RememberPartitionIndexProps(oldId, stmt);
 			if (!rewrite)
 				TryReuseIndex(oldId, stmt);
 			stmt->reset_default_tblspc = true;
@@ -16466,6 +16479,7 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, Oid ownerId,
 					indstmt = castNode(IndexStmt, cmd->def);
 					indoid = get_constraint_index(oldId);
 
+					RememberPartitionIndexProps(indoid, indstmt);
 					if (!rewrite)
 						TryReuseIndex(indoid, indstmt);
 					/* keep any comment on the index */
@@ -16620,6 +16634,83 @@ RebuildConstraintComment(AlteredTableInfo *tab, AlterTablePass pass, Oid objid,
 	tab->subcmds[pass] = lappend(tab->subcmds[pass], newcmd);
 }
 
+/*
+ * Before a partitioned index is dropped and rebuilt as part of an ALTER
+ * COLUMN TYPE (or SET EXPRESSION), capture each of its descendant indexes'
+ * properties not preserved by the cloned rebuild definition (name, comment,
+ * replica identity, cluster-on, per-column stat targets) into a list saved on
+ * the parent index's IndexStmt. These would otherwise be lost when recreating
+ * the leaf index.
+ *
+ * Must run before the drop, while the old descendant indexes still exist.
+ */
+static void
+RememberPartitionIndexProps(Oid indoid, IndexStmt *stmt)
+{
+	List	   *indexOids;
+
+	if (get_rel_relkind(indoid) != RELKIND_PARTITIONED_INDEX)
+		return;
+
+	indexOids = find_all_inheritors(indoid, NoLock, NULL);
+	foreach_oid(leafIndexOid, indexOids)
+	{
+		PartitionIndexProps *props;
+		HeapTuple	idxtup;
+		HeapTuple	classtup;
+		Form_pg_index idxform;
+		Form_pg_class classform;
+		Datum		reloptions;
+		bool		rel_isnull;
+
+		if (leafIndexOid == indoid)
+			continue;
+
+		idxtup = SearchSysCache1(INDEXRELID, ObjectIdGetDatum(leafIndexOid));
+		if (!HeapTupleIsValid(idxtup))
+			continue;
+		idxform = (Form_pg_index) GETSTRUCT(idxtup);
+
+		classtup = SearchSysCache1(RELOID, ObjectIdGetDatum(leafIndexOid));
+		if (!HeapTupleIsValid(classtup))
+			elog(ERROR, "cache lookup failed for relation %u", leafIndexOid);
+		classform = (Form_pg_class) GETSTRUCT(classtup);
+
+		props = makeNode(PartitionIndexProps);
+		props->partrelid = idxform->indrelid;
+		props->idxname = pstrdup(NameStr(classform->relname));
+		props->idxcomment = GetComment(leafIndexOid, RelationRelationId, 0);
+		props->constraintcomment = NULL;
+		if (OidIsValid(get_index_constraint(leafIndexOid)))
+			props->constraintcomment =
+				GetComment(get_index_constraint(leafIndexOid), ConstraintRelationId, 0);
+		props->isreplident = idxform->indisreplident;
+		props->isclustered = idxform->indisclustered;
+		props->reloptions = NIL;
+		if (OidIsValid(classform->reltablespace))
+			props->tableSpace = get_tablespace_name(classform->reltablespace);
+		else if (classform->relkind != RELKIND_PARTITIONED_INDEX)
+			/* Avoid default_tablespace changing a storage-bearing index. */
+			props->tableSpace = pstrdup("pg_default");
+		else
+			/* CREATE INDEX cannot specify pg_default for a partitioned index. */
+			props->tableSpace = NULL;
+
+		reloptions = SysCacheGetAttr(RELOID, classtup,
+									 Anum_pg_class_reloptions, &rel_isnull);
+		if (!rel_isnull)
+			props->reloptions = untransformRelOptions(reloptions);
+
+		ReleaseSysCache(classtup);
+		ReleaseSysCache(idxtup);
+
+		props->stattargets = GetIndexStatTargets(leafIndexOid);
+
+		stmt->oldPartIndexProps = lappend(stmt->oldPartIndexProps, props);
+	}
+	list_free(indexOids);
+}
+
 /*
  * Collect the per-column statistics targets of an index into a list of
  * IndexStatTarget nodes. Returns NIL if none are set.
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 9c753ec13a8..9aed48da93a 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -3659,13 +3659,25 @@ typedef struct IndexStmt
 										 * executing */
 
 	/*
-	 * When doing an operation on the index that causes it to be dropped and
-	 * recreated, these properties are not automatically cloned from the old
-	 * index to the new and must be explicitly saved before dropping the old
-	 * index and restored after creating the new index.
+	 * Properties not preserved by a cloned rebuild definition.
 	 */
+	bool		idxisreplident; /* restore this index as REPLICA IDENTITY */
+	bool		idxisclustered; /* restore CLUSTER ON this index */
 	char	   *idxcomment;		/* comment to apply to index, or NULL */
+	char	   *idxconstraintcomment;	/* comment to apply to index
+										 * constraint */
 	List	   *idxstattargets; /* list of IndexStatTarget to restore */
+
+	/*
+	 * For a partitioned index, oldPartIndexProps holds one entry per old
+	 * descendant index. The whole list is propagated to every IndexStmt in
+	 * DefineIndex()'s recursion so that intermediate levels can still find
+	 * deeper leaves' entries. Each stmt then copies its own matching entry
+	 * into the corresponding scalar fields in the IndexStmt (see
+	 * TransferPartitionIndexProps).
+	 */
+	List	   *oldPartIndexProps;	/* list of PartitionIndexProps
+									 * (partitioned index rebuild only) */
 } IndexStmt;
 
 /* one per-column statistics target carried across an index rebuild */
@@ -3678,6 +3690,28 @@ typedef struct IndexStatTarget
 	int16		stattarget;		/* attstattarget value to restore */
 } IndexStatTarget;
 
+/*
+ * Properties of one old descendant partition index that are not preserved by
+ * the cloned rebuild definition, captured before ALTER COLUMN TYPE or SET
+ * EXPRESSION drops the index.
+ */
+typedef struct PartitionIndexProps
+{
+	pg_node_attr(no_equal, no_query_jumble)
+
+	NodeTag		type;
+	Oid			partrelid;		/* partition table owning this index */
+	char	   *idxname;		/* index name to restore */
+	char	   *idxcomment;		/* comment, or NULL */
+	char	   *constraintcomment;	/* index constraint comment, or NULL */
+	bool		isreplident;	/* was replica identity */
+	bool		isclustered;	/* was clustered on */
+	List	   *stattargets;	/* list of IndexStatTarget */
+	List	   *reloptions;		/* index's own reloptions (untransformed
+								 * DefElem list), or NIL */
+	char	   *tableSpace;		/* index's own tablespace for the rebuild */
+} PartitionIndexProps;
+
 /* ----------------------
  *		Create Statistics Statement
  * ----------------------
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index 8b6d85461f9..4457b81b612 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -2304,23 +2304,23 @@ select relname,
   from pg_class c left join old_oids using (relname)
   where relname like 'at_partitioned%'
   order by relname;
-           relname            | orig_oid | storage |     desc     
-------------------------------+----------+---------+--------------
+           relname            | orig_oid | storage |     desc      
+------------------------------+----------+---------+---------------
  at_partitioned               | t        | none    | 
  at_partitioned_0             | t        | own     | 
- at_partitioned_0_id_name_key | f        | own     | 
+ at_partitioned_0_id_name_key | f        | own     | child 0 index
  at_partitioned_1             | t        | own     | 
- at_partitioned_1_id_name_key | f        | own     | 
+ at_partitioned_1_id_name_key | f        | own     | child 1 index
  at_partitioned_id_name_key   | f        | none    | parent index
 (6 rows)
 
 select conname, obj_description(oid, 'pg_constraint') as desc
   from pg_constraint where conname like 'at_partitioned%'
   order by conname;
-           conname            |       desc        
-------------------------------+-------------------
- at_partitioned_0_id_name_key | 
- at_partitioned_1_id_name_key | 
+           conname            |        desc        
+------------------------------+--------------------
+ at_partitioned_0_id_name_key | child 0 constraint
+ at_partitioned_1_id_name_key | child 1 constraint
  at_partitioned_id_name_key   | parent constraint
 (3 rows)
 
@@ -2346,6 +2346,150 @@ select c.relname, a.attnum, a.attstattarget
 (3 rows)
 
 drop table at_reb_plain;
+-- Partitioned tables' leaf partitions should all preserve their properties
+-- across an ALTER COLUMN TYPE-triggered rebuild.
+create table at_reb (id int not null, val int not null) partition by range (id);
+create table at_reb_mid (id int not null, val int not null) partition by range (id);
+create table at_reb_1 partition of at_reb_mid for values from (0) to (50);
+create table at_reb_2 partition of at_reb_mid for values from (50) to (100);
+alter table at_reb attach partition at_reb_mid for values from (0) to (100);
+create index at_reb_expr on at_reb ((val + 1))
+  with (fillfactor = 71) tablespace regress_tblspace;
+create unique index at_reb_uniq on at_reb (id, val);
+-- Give descendant indexes stable, custom names (also exercises preservation).
+alter index at_reb_mid_val_1_idx rename to at_reb_expr_mid;
+alter index at_reb_mid_id_val_idx rename to at_reb_uniq_mid;
+alter index at_reb_1_val_1_idx rename to at_reb_expr_leaf;
+alter index at_reb_1_id_val_idx rename to at_reb_uniq_leaf;
+alter index at_reb_2_val_1_idx rename to at_reb_expr_leaf_2;
+alter index at_reb_2_id_val_idx rename to at_reb_uniq_leaf_2;
+-- The leaf can use a different tablespace from its parent index.
+alter index at_reb_expr_leaf set tablespace pg_default;
+-- Load the leaf indexes with properties not preserved by the cloned definition.
+comment on index at_reb_expr_mid is 'mid expr index comment';
+comment on index at_reb_expr_leaf is 'leaf expr index comment';
+comment on index at_reb_expr_leaf_2 is 'second leaf expr index comment';
+alter index at_reb_expr_mid alter column 1 set statistics 654;
+alter index at_reb_expr_leaf alter column 1 set statistics 543;
+alter index at_reb_expr_leaf_2 alter column 1 set statistics 432;
+alter index at_reb_expr_leaf set (fillfactor = 55);
+alter index at_reb_expr_leaf_2 set (fillfactor = 66);
+alter table at_reb_mid replica identity using index at_reb_uniq_mid;
+alter table at_reb_1 replica identity using index at_reb_uniq_leaf;
+alter table at_reb_2 replica identity using index at_reb_uniq_leaf_2;
+alter table at_reb_1 cluster on at_reb_expr_leaf;
+alter table at_reb_2 cluster on at_reb_uniq_leaf_2;
+-- Snapshot each catalog row describing the two leaf indexes as jsonb. Remove
+-- the columns that legitimately differ across the rebuild: the physical
+-- identity (oid, relfilenode, and their indexrelid/indrelid/attrelid echoes),
+-- and the size estimates (relpages, reltuples, relallvisible, relallfrozen),
+-- which are recomputed by the fresh index build rather than carried over. Any
+-- other difference fails the test.
+create function at_reb_snapshot() returns table(cat text, disc text, body jsonb)
+language sql stable as $$
+  select 'pg_class', c.relname,
+         to_jsonb(c) - '{oid,relfilenode,relpages,reltuples,relallvisible,
+                         relallfrozen}'::text[]
+  from pg_class c
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+  union all
+  select 'pg_index', c.relname,
+         to_jsonb(i) - '{indexrelid,indrelid}'::text[]
+  from pg_index i join pg_class c on c.oid = i.indexrelid
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+  union all
+  select 'pg_attribute', c.relname || '.' || a.attnum,
+         to_jsonb(a) - '{attrelid}'::text[]
+  from pg_attribute a join pg_class c on c.oid = a.attrelid
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+    and a.attnum > 0
+  union all
+  select 'comment', c.relname, to_jsonb(obj_description(c.oid, 'pg_class'))
+  from pg_class c
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+  union all
+  -- owning table row, for relreplident (replica identity is recorded here too)
+  select 'pg_class_owner', tc.relname, to_jsonb(tc.relreplident)
+  from pg_index i join pg_class c on c.oid = i.indexrelid
+    join pg_class tc on tc.oid = i.indrelid
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2');
+$$;
+create temp table at_reb_before as select * from at_reb_snapshot();
+-- No-op type change: same type, but still rebuilds the leaf indexes.
+set default_tablespace = 'regress_tblspace';
+alter table at_reb alter column val type int;
+reset default_tablespace;
+-- The leaf's explicitly different tablespace must survive the rebuild.
+select c.relname, coalesce(s.spcname, '<db default>') as tablespace
+  from pg_class c left join pg_tablespace s on s.oid = c.reltablespace
+  where c.relname in ('at_reb_expr', 'at_reb_expr_leaf')
+  order by 1;
+     relname      |    tablespace    
+------------------+------------------
+ at_reb_expr      | regress_tblspace
+ at_reb_expr_leaf | <db default>
+(2 rows)
+
+create temp table at_reb_after as select * from at_reb_snapshot();
+-- Both directions must be empty: nothing lost, nothing unexpectedly changed.
+select 'lost/changed' as dir, cat, disc from (
+  select * from at_reb_before except select * from at_reb_after) d
+union all
+select 'appeared/changed', cat, disc from (
+  select * from at_reb_after except select * from at_reb_before) d
+order by 1, 2, 3;
+ dir | cat | disc 
+-----+-----+------
+(0 rows)
+
+drop function at_reb_snapshot();
+drop table at_reb;
+-- SET EXPRESSION rebuilds the same partition index hierarchy.
+create table at_reb_set
+  (id int not null, val int not null,
+   gen int generated always as (val + 1) stored) partition by range (id);
+create table at_reb_set_mid
+  (id int not null, val int not null,
+   gen int generated always as (val + 1) stored) partition by range (id);
+create table at_reb_set_leaf
+  (id int not null, val int not null,
+   gen int generated always as (val + 1) stored);
+create index at_reb_set_leaf_custom_idx on at_reb_set_leaf ((gen + 1));
+comment on index at_reb_set_leaf_custom_idx is 'set expression index comment';
+alter index at_reb_set_leaf_custom_idx alter column 1 set statistics 654;
+alter table at_reb_set_mid attach partition at_reb_set_leaf
+  for values from (0) to (100);
+alter table at_reb_set attach partition at_reb_set_mid
+  for values from (0) to (1000);
+create index at_reb_set_idx on at_reb_set ((gen + 1));
+create temp table at_reb_set_before as
+  select obj_description('at_reb_set_leaf_custom_idx'::regclass) as comment,
+         (select attstattarget from pg_attribute
+          where attrelid = 'at_reb_set_leaf_custom_idx'::regclass
+            and attnum = 1) as stattarget;
+alter table at_reb_set alter column gen set expression as (val + 2);
+select * from at_reb_set_before
+except
+select obj_description('at_reb_set_leaf_custom_idx'::regclass),
+       (select attstattarget from pg_attribute
+        where attrelid = 'at_reb_set_leaf_custom_idx'::regclass
+          and attnum = 1);
+ comment | stattarget 
+---------+------------
+(0 rows)
+
+drop table at_reb_set_before;
+drop table at_reb_set;
 -- disallow recursive containment of row types
 create temp table recur1 (f1 int);
 alter table recur1 add column f2 recur1; -- fails
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 8bdfc4262ef..5fb030ed76c 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -1545,6 +1545,146 @@ select c.relname, a.attnum, a.attstattarget
   order by c.relname, a.attnum;
 drop table at_reb_plain;
 
+-- Partitioned tables' leaf partitions should all preserve their properties
+-- across an ALTER COLUMN TYPE-triggered rebuild.
+create table at_reb (id int not null, val int not null) partition by range (id);
+create table at_reb_mid (id int not null, val int not null) partition by range (id);
+create table at_reb_1 partition of at_reb_mid for values from (0) to (50);
+create table at_reb_2 partition of at_reb_mid for values from (50) to (100);
+alter table at_reb attach partition at_reb_mid for values from (0) to (100);
+create index at_reb_expr on at_reb ((val + 1))
+  with (fillfactor = 71) tablespace regress_tblspace;
+create unique index at_reb_uniq on at_reb (id, val);
+-- Give descendant indexes stable, custom names (also exercises preservation).
+alter index at_reb_mid_val_1_idx rename to at_reb_expr_mid;
+alter index at_reb_mid_id_val_idx rename to at_reb_uniq_mid;
+alter index at_reb_1_val_1_idx rename to at_reb_expr_leaf;
+alter index at_reb_1_id_val_idx rename to at_reb_uniq_leaf;
+alter index at_reb_2_val_1_idx rename to at_reb_expr_leaf_2;
+alter index at_reb_2_id_val_idx rename to at_reb_uniq_leaf_2;
+-- The leaf can use a different tablespace from its parent index.
+alter index at_reb_expr_leaf set tablespace pg_default;
+
+-- Load the leaf indexes with properties not preserved by the cloned definition.
+comment on index at_reb_expr_mid is 'mid expr index comment';
+comment on index at_reb_expr_leaf is 'leaf expr index comment';
+comment on index at_reb_expr_leaf_2 is 'second leaf expr index comment';
+alter index at_reb_expr_mid alter column 1 set statistics 654;
+alter index at_reb_expr_leaf alter column 1 set statistics 543;
+alter index at_reb_expr_leaf_2 alter column 1 set statistics 432;
+alter index at_reb_expr_leaf set (fillfactor = 55);
+alter index at_reb_expr_leaf_2 set (fillfactor = 66);
+alter table at_reb_mid replica identity using index at_reb_uniq_mid;
+alter table at_reb_1 replica identity using index at_reb_uniq_leaf;
+alter table at_reb_2 replica identity using index at_reb_uniq_leaf_2;
+alter table at_reb_1 cluster on at_reb_expr_leaf;
+alter table at_reb_2 cluster on at_reb_uniq_leaf_2;
+
+-- Snapshot each catalog row describing the two leaf indexes as jsonb. Remove
+-- the columns that legitimately differ across the rebuild: the physical
+-- identity (oid, relfilenode, and their indexrelid/indrelid/attrelid echoes),
+-- and the size estimates (relpages, reltuples, relallvisible, relallfrozen),
+-- which are recomputed by the fresh index build rather than carried over. Any
+-- other difference fails the test.
+create function at_reb_snapshot() returns table(cat text, disc text, body jsonb)
+language sql stable as $$
+  select 'pg_class', c.relname,
+         to_jsonb(c) - '{oid,relfilenode,relpages,reltuples,relallvisible,
+                         relallfrozen}'::text[]
+  from pg_class c
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+  union all
+  select 'pg_index', c.relname,
+         to_jsonb(i) - '{indexrelid,indrelid}'::text[]
+  from pg_index i join pg_class c on c.oid = i.indexrelid
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+  union all
+  select 'pg_attribute', c.relname || '.' || a.attnum,
+         to_jsonb(a) - '{attrelid}'::text[]
+  from pg_attribute a join pg_class c on c.oid = a.attrelid
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+    and a.attnum > 0
+  union all
+  select 'comment', c.relname, to_jsonb(obj_description(c.oid, 'pg_class'))
+  from pg_class c
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2')
+  union all
+  -- owning table row, for relreplident (replica identity is recorded here too)
+  select 'pg_class_owner', tc.relname, to_jsonb(tc.relreplident)
+  from pg_index i join pg_class c on c.oid = i.indexrelid
+    join pg_class tc on tc.oid = i.indrelid
+  where c.relname in ('at_reb_expr_mid', 'at_reb_uniq_mid',
+                      'at_reb_expr_leaf', 'at_reb_uniq_leaf',
+                      'at_reb_expr_leaf_2', 'at_reb_uniq_leaf_2');
+$$;
+
+create temp table at_reb_before as select * from at_reb_snapshot();
+
+-- No-op type change: same type, but still rebuilds the leaf indexes.
+set default_tablespace = 'regress_tblspace';
+alter table at_reb alter column val type int;
+reset default_tablespace;
+
+-- The leaf's explicitly different tablespace must survive the rebuild.
+select c.relname, coalesce(s.spcname, '<db default>') as tablespace
+  from pg_class c left join pg_tablespace s on s.oid = c.reltablespace
+  where c.relname in ('at_reb_expr', 'at_reb_expr_leaf')
+  order by 1;
+
+create temp table at_reb_after as select * from at_reb_snapshot();
+
+-- Both directions must be empty: nothing lost, nothing unexpectedly changed.
+select 'lost/changed' as dir, cat, disc from (
+  select * from at_reb_before except select * from at_reb_after) d
+union all
+select 'appeared/changed', cat, disc from (
+  select * from at_reb_after except select * from at_reb_before) d
+order by 1, 2, 3;
+
+drop function at_reb_snapshot();
+drop table at_reb;
+
+-- SET EXPRESSION rebuilds the same partition index hierarchy.
+create table at_reb_set
+  (id int not null, val int not null,
+   gen int generated always as (val + 1) stored) partition by range (id);
+create table at_reb_set_mid
+  (id int not null, val int not null,
+   gen int generated always as (val + 1) stored) partition by range (id);
+create table at_reb_set_leaf
+  (id int not null, val int not null,
+   gen int generated always as (val + 1) stored);
+create index at_reb_set_leaf_custom_idx on at_reb_set_leaf ((gen + 1));
+comment on index at_reb_set_leaf_custom_idx is 'set expression index comment';
+alter index at_reb_set_leaf_custom_idx alter column 1 set statistics 654;
+alter table at_reb_set_mid attach partition at_reb_set_leaf
+  for values from (0) to (100);
+alter table at_reb_set attach partition at_reb_set_mid
+  for values from (0) to (1000);
+create index at_reb_set_idx on at_reb_set ((gen + 1));
+create temp table at_reb_set_before as
+  select obj_description('at_reb_set_leaf_custom_idx'::regclass) as comment,
+         (select attstattarget from pg_attribute
+          where attrelid = 'at_reb_set_leaf_custom_idx'::regclass
+            and attnum = 1) as stattarget;
+alter table at_reb_set alter column gen set expression as (val + 2);
+select * from at_reb_set_before
+except
+select obj_description('at_reb_set_leaf_custom_idx'::regclass),
+       (select attstattarget from pg_attribute
+        where attrelid = 'at_reb_set_leaf_custom_idx'::regclass
+          and attnum = 1);
+drop table at_reb_set_before;
+drop table at_reb_set;
+
 -- disallow recursive containment of row types
 create temp table recur1 (f1 int);
 alter table recur1 add column f2 recur1; -- fails
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 6a464f80de1..14a5b66f579 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2218,6 +2218,7 @@ PartitionDispatch
 PartitionElem
 PartitionHashBound
 PartitionIndexExtDepEntry
+PartitionIndexProps
 PartitionKey
 PartitionListValue
 PartitionMap
-- 
2.50.1 (Apple Git-155)

