From 2c570c3c388b0c6142600c580b064e2b969fece7 Mon Sep 17 00:00:00 2001
From: Melanie Plageman <melanieplageman@gmail.com>
Date: Sat, 5 Sep 2026 03:26:38 +0000
Subject: [PATCH v19 3/3] Preserve descendant partition index properties across
 ALTER COLUMN TYPE

When ALTER TABLE ... ALTER COLUMN TYPE or SET EXPRESSION rebuilds a
partitioned index, descendant partition indexes are recreated from the
parent definition. Descendant-only properties such as names, comments,
replica identity, clustering, per-column statistics targets, reloptions,
tablespaces, and auto-extension dependencies were lost.

Save those properties before dropping the old hierarchy and restore them
on the replacement descendant indexes and constraints. Also treat saved
descendant names as reserved during the rebuild, because pg_class alone
does not see names that will be restored later in the same command.

Propagate reset_default_tblspc into descendant IndexStmts so that a
session default_tablespace setting does not move rebuilt indexes that
used the database default tablespace.
---
 src/backend/commands/indexcmds.c          | 207 ++++++++++++++++++-
 src/backend/commands/repack.c             |   3 +-
 src/backend/commands/tablecmds.c          |  99 ++++++++-
 src/backend/parser/parse_utilcmd.c        |   3 +-
 src/include/commands/defrem.h             |   2 +-
 src/include/nodes/parsenodes.h            |  43 +++-
 src/test/regress/expected/alter_table.out | 234 +++++++++++++++++++---
 src/test/regress/sql/alter_table.sql      | 193 ++++++++++++++++--
 src/tools/pgindent/typedefs.list          |   1 +
 9 files changed, 721 insertions(+), 64 deletions(-)

diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index 151a9952522..b76e79e19f8 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -107,8 +107,16 @@ static void ComputeIndexAttrs(ParseState *pstate,
 							  int *ddl_save_nestlevel);
 static char *ChooseIndexName(const char *tabname, Oid namespaceId,
 							 const List *colnames, const List *exclusionOpNames,
-							 bool primary, bool isconstraint);
+							 bool primary, bool isconstraint,
+							 List *others);
 static char *ChooseIndexNameAddition(const List *colnames);
+static List *CollectReservedIndexNames(const IndexStmt *stmt,
+									   Oid tableId,
+									   Oid namespaceId);
+static bool IndexNameIsReserved(const List *reserved_names,
+								const char *indexname);
+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,
@@ -557,6 +565,89 @@ 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 descendant 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;
+		childStmt->idxextensionOids = props->extensionOids;
+
+		/*
+		 * Override the parent's reloptions with the descendant's own, so
+		 * independently-set descendant options survive.
+		 */
+		childStmt->options = props->reloptions;
+		childStmt->tableSpace = props->tableSpace ?
+			pstrdup(props->tableSpace) : NULL;
+		childStmt->reset_default_tblspc = stmt->reset_default_tblspc;
+		return;
+	}
+}
+
+/*
+ * Collect relation names that an auto-generated child index name must avoid
+ * because a later step in the same rebuild will restore them explicitly.
+ */
+static List *
+CollectReservedIndexNames(const IndexStmt *stmt, Oid tableId,
+						  Oid namespaceId)
+{
+	List	   *others = NIL;
+
+	foreach_node(PartitionIndexProps, props, stmt->oldPartIndexProps)
+	{
+		if (props->partrelid == tableId)
+			continue;
+
+		if (get_rel_namespace(props->partrelid) != namespaceId)
+			continue;
+
+		others = lappend(others, props->idxname);
+	}
+
+	return others;
+}
+
+static bool
+IndexNameIsReserved(const List *reserved_names, const char *indexname)
+{
+	ListCell   *l;
+
+	foreach(l, reserved_names)
+	{
+		if (strcmp((char *) lfirst(l), indexname) == 0)
+			return true;
+	}
+
+	return false;
+}
+
+
 /*
  * DefineIndex
  *		Creates a new index.
@@ -888,12 +979,21 @@ DefineIndex(ParseState *pstate,
 	 */
 	indexRelationName = stmt->idxname;
 	if (indexRelationName == NULL)
+	{
+		List	   *reserved_names = NIL;
+
+		if (stmt->oldPartIndexProps != NIL)
+			reserved_names = CollectReservedIndexNames(stmt, tableId,
+													   namespaceId);
 		indexRelationName = ChooseIndexName(RelationGetRelationName(rel),
 											namespaceId,
 											indexColNames,
 											stmt->excludeOpNames,
 											stmt->primary,
-											stmt->isconstraint);
+											stmt->isconstraint,
+											reserved_names);
+		list_free(reserved_names);
+	}
 
 	/*
 	 * look up the access method, verify it can handle the requested features
@@ -1374,6 +1474,10 @@ DefineIndex(ParseState *pstate,
 	if (stmt->idxcomment != NULL)
 		CreateComments(indexRelationId, RelationRelationId, 0,
 					   stmt->idxcomment);
+	if (stmt->idxconstraintcomment != NULL)
+		CreateComments(createdConstraintId, ConstraintRelationId, 0,
+					   stmt->idxconstraintcomment);
+
 	if (stmt->idxextensionOids != NIL)
 	{
 		ObjectAddress indexAddress;
@@ -1392,6 +1496,62 @@ DefineIndex(ParseState *pstate,
 	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;
@@ -1614,6 +1774,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.
@@ -2713,6 +2884,10 @@ makeObjectName(const char *name1, const char *name2, const char *label)
  * should be unique within schemas, so we follow that for autogenerated
  * constraint names.)
  *
+ * 'others' can be a list of relation names already chosen within the current
+ * command, or otherwise reserved by it, but not yet visible in the catalogs;
+ * we will not choose a duplicate of one of these either.
+ *
  * Note: it is theoretically possible to get a collision anyway, if someone
  * else chooses the same name concurrently.  We shorten the race condition
  * window by checking for conflicting relations using SnapshotDirty, but
@@ -2727,7 +2902,7 @@ makeObjectName(const char *name1, const char *name2, const char *label)
 char *
 ChooseRelationName(const char *name1, const char *name2,
 				   const char *label, Oid namespaceid,
-				   bool isconstraint)
+				   bool isconstraint, List *others)
 {
 	int			pass = 0;
 	char	   *relname = NULL;
@@ -2747,6 +2922,7 @@ ChooseRelationName(const char *name1, const char *name2,
 		ScanKeyData key[2];
 		SysScanDesc scan;
 		bool		collides;
+		bool		reserved = false;
 
 		relname = makeObjectName(name1, name2, modlabel);
 
@@ -2769,8 +2945,11 @@ ChooseRelationName(const char *name1, const char *name2,
 
 		systable_endscan(scan);
 
-		/* break out of loop if no conflict */
 		if (!collides)
+			reserved = IndexNameIsReserved(others, relname);
+
+		/* break out of loop if no conflict */
+		if (!collides && !reserved)
 		{
 			if (!isconstraint ||
 				!ConstraintNameExists(relname, namespaceid))
@@ -2795,7 +2974,7 @@ ChooseRelationName(const char *name1, const char *name2,
 static char *
 ChooseIndexName(const char *tabname, Oid namespaceId,
 				const List *colnames, const List *exclusionOpNames,
-				bool primary, bool isconstraint)
+				bool primary, bool isconstraint, List *others)
 {
 	char	   *indexname;
 
@@ -2806,7 +2985,8 @@ ChooseIndexName(const char *tabname, Oid namespaceId,
 									   NULL,
 									   "pkey",
 									   namespaceId,
-									   true);
+									   true,
+									   others);
 	}
 	else if (exclusionOpNames != NIL)
 	{
@@ -2814,7 +2994,8 @@ ChooseIndexName(const char *tabname, Oid namespaceId,
 									   ChooseIndexNameAddition(colnames),
 									   "excl",
 									   namespaceId,
-									   true);
+									   true,
+									   others);
 	}
 	else if (isconstraint)
 	{
@@ -2822,7 +3003,8 @@ ChooseIndexName(const char *tabname, Oid namespaceId,
 									   ChooseIndexNameAddition(colnames),
 									   "key",
 									   namespaceId,
-									   true);
+									   true,
+									   others);
 	}
 	else
 	{
@@ -2830,7 +3012,8 @@ ChooseIndexName(const char *tabname, Oid namespaceId,
 									   ChooseIndexNameAddition(colnames),
 									   "idx",
 									   namespaceId,
-									   false);
+									   false,
+									   others);
 	}
 
 	return indexname;
@@ -4201,7 +4384,8 @@ ReindexRelationConcurrently(const ReindexStmt *stmt, Oid relationOid, const Rein
 											NULL,
 											"ccnew",
 											get_rel_namespace(indexRel->rd_index->indrelid),
-											false);
+											false,
+											NIL);
 
 		/* Choose the new tablespace, indexes of toast tables are not moved */
 		if (OidIsValid(params->tablespaceOid) &&
@@ -4513,7 +4697,8 @@ ReindexRelationConcurrently(const ReindexStmt *stmt, Oid relationOid, const Rein
 									 NULL,
 									 "ccold",
 									 get_rel_namespace(oldidx->tableId),
-									 false);
+									 false,
+									 NIL);
 
 		/*
 		 * Swapping the indexes might involve TOAST table access, so ensure we
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 477c86b2ba6..5c87020609f 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -3433,7 +3433,8 @@ build_new_indexes(Relation NewHeap, Relation OldHeap, List *OldIndexes)
 									 NULL,
 									 "repacknew",
 									 get_rel_namespace(ind->rd_index->indrelid),
-									 false);
+									 false,
+									 NIL);
 		newindex = index_create_copy(NewHeap, INDEX_CREATE_SUPPRESS_PROGRESS,
 									 oldindex, ind->rd_rel->reltablespace,
 									 newName);
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index d1afed33efe..ae46af11913 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -685,6 +685,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,
@@ -15920,6 +15921,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-descendant 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)
@@ -16374,16 +16382,21 @@ 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;
 			/* keep the index's comment */
 			stmt->idxcomment = GetComment(oldId, RelationRelationId, 0);
+			/* keep the index's per-column statistics targets */
+			stmt->idxstattargets = GetIndexStatTargets(oldId);
 			/* keep any auto-extension dependencies */
 			stmt->idxextensionOids =
 				getAutoExtensionsOfObject(RelationRelationId, oldId);
-			/* keep the index's per-column statistics targets */
-			stmt->idxstattargets = GetIndexStatTargets(oldId);
 
 			newcmd = makeNode(AlterTableCmd);
 			newcmd->subtype = AT_ReAddIndex;
@@ -16408,16 +16421,17 @@ 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 */
 					indstmt->idxcomment = GetComment(indoid,
 													 RelationRelationId, 0);
+					/* keep the index's per-column statistics targets */
+					indstmt->idxstattargets = GetIndexStatTargets(indoid);
 					/* keep any auto-extension dependencies */
 					indstmt->idxextensionOids =
 						getAutoExtensionsOfObject(RelationRelationId, indoid);
-					/* keep the index's per-column statistics targets */
-					indstmt->idxstattargets = GetIndexStatTargets(indoid);
 					indstmt->reset_default_tblspc = true;
 
 					cmd->subtype = AT_ReAddIndex;
@@ -16565,6 +16579,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 descendant index.
+ *
+ * Must run before the drop, while the old descendant indexes still exist.
+ */
+static void
+RememberPartitionIndexProps(Oid indoid, IndexStmt *stmt)
+{
+	List	   *descendantIndexOids;
+
+	if (get_rel_relkind(indoid) != RELKIND_PARTITIONED_INDEX)
+		return;
+
+	descendantIndexOids = find_all_inheritors(indoid, NoLock, NULL);
+	foreach_oid(descendantIndexOid, descendantIndexOids)
+	{
+		PartitionIndexProps *props;
+		HeapTuple	idxtup;
+		HeapTuple	classtup;
+		Form_pg_index idxform;
+		Form_pg_class classform;
+		Oid			constraintOid;
+		Datum		reloptions;
+		bool		rel_isnull;
+
+		if (descendantIndexOid == indoid)
+			continue;
+
+		idxtup = SearchSysCache1(INDEXRELID, ObjectIdGetDatum(descendantIndexOid));
+		if (!HeapTupleIsValid(idxtup))
+			continue;
+		idxform = (Form_pg_index) GETSTRUCT(idxtup);
+
+		classtup = SearchSysCache1(RELOID, ObjectIdGetDatum(descendantIndexOid));
+		if (!HeapTupleIsValid(classtup))
+			elog(ERROR, "cache lookup failed for relation %u", descendantIndexOid);
+		classform = (Form_pg_class) GETSTRUCT(classtup);
+
+		props = makeNode(PartitionIndexProps);
+		props->partrelid = idxform->indrelid;
+		props->idxname = pstrdup(NameStr(classform->relname));
+		props->idxcomment = GetComment(descendantIndexOid, RelationRelationId, 0);
+		props->constraintcomment = NULL;
+		constraintOid = get_index_constraint(descendantIndexOid);
+		if (OidIsValid(constraintOid))
+			props->constraintcomment =
+				GetComment(constraintOid, ConstraintRelationId, 0);
+		props->isreplident = idxform->indisreplident;
+		props->isclustered = idxform->indisclustered;
+		props->reloptions = NIL;
+		props->extensionOids =
+			getAutoExtensionsOfObject(RelationRelationId, descendantIndexOid);
+		if (OidIsValid(classform->reltablespace))
+			props->tableSpace = get_tablespace_name(classform->reltablespace);
+		else
+			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(descendantIndexOid);
+
+		stmt->oldPartIndexProps = lappend(stmt->oldPartIndexProps, props);
+	}
+	list_free(descendantIndexOids);
+}
+
 /*
  * 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/backend/parser/parse_utilcmd.c b/src/backend/parser/parse_utilcmd.c
index d83616a8507..7ac4e5e704b 100644
--- a/src/backend/parser/parse_utilcmd.c
+++ b/src/backend/parser/parse_utilcmd.c
@@ -472,7 +472,8 @@ generateSerialExtraStmts(CreateStmtContext *cxt, ColumnDef *column,
 								   column->colname,
 								   "seq",
 								   snamespaceid,
-								   false);
+								   false,
+								   NIL);
 	}
 
 	ereport(DEBUG1,
diff --git a/src/include/commands/defrem.h b/src/include/commands/defrem.h
index 574f860bdd2..3ae1de44232 100644
--- a/src/include/commands/defrem.h
+++ b/src/include/commands/defrem.h
@@ -42,7 +42,7 @@ extern char *makeObjectName(const char *name1, const char *name2,
 							const char *label);
 extern char *ChooseRelationName(const char *name1, const char *name2,
 								const char *label, Oid namespaceid,
-								bool isconstraint);
+								bool isconstraint, List *others);
 extern bool CheckIndexCompatible(Oid oldId,
 								 const char *accessMethodName,
 								 const List *attributeList,
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index c9421084208..f80403e57df 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -3631,14 +3631,26 @@ 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 */
 	List	   *idxextensionOids;	/* extensions to depend on */
+
+	/*
+	 * 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 descendants' 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 */
@@ -3651,6 +3663,29 @@ 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	   *extensionOids;	/* extensions this index depends on */
+	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 b52def050fa..743b0580f90 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -2240,6 +2240,32 @@ Indexes:
     "at_part_2_b_idx" btree (b)
 
 drop table at_partitioned;
+-- Preserve explicitly renamed descendant index names across partitioned index
+-- rebuilds, and avoid auto-generating the same name for a missing sibling.
+create schema alter_type_partidx;
+set search_path = alter_type_partidx;
+create table p (id int not null, a int not null) partition by list (id);
+create table p1 partition of p for values in (1);
+create table p2 partition of p for values in (2);
+create index p_idx on only p (a);
+create index tmp on p2 (a);
+alter index p_idx attach partition tmp;
+alter index tmp rename to p1_a_idx;
+alter table p alter column a type bigint;
+select i.indrelid::regclass::text as partition, c.relname as index_name
+from pg_index i
+join pg_class c on c.oid = i.indexrelid
+where i.indexrelid in (select inhrelid from pg_inherits where inhparent = 'p_idx'::regclass)
+order by 1;
+ partition | index_name 
+-----------+------------
+ p1        | p1_a_idx1
+ p2        | p1_a_idx
+(2 rows)
+
+reset search_path;
+drop table alter_type_partidx.p;
+drop schema alter_type_partidx;
 -- Alter column type when no table rewrite is required
 -- Also check that comments are preserved
 create table at_partitioned(id int, name varchar(64), unique (id, name))
@@ -2304,55 +2330,38 @@ 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)
 
 -- Don't remove this DROP, it exposes bug #15672
 drop table at_partitioned;
-create table at_reb_dep (id int not null, val int not null);
-create index at_reb_dep_expr on at_reb_dep ((val + 1));
-alter table at_reb_dep add constraint at_reb_dep_key unique (id, val);
-alter index at_reb_dep_expr depends on extension plpgsql;
-alter index at_reb_dep_key depends on extension plpgsql;
-alter table at_reb_dep alter column val type bigint;
-select c.relname, d.deptype, e.extname
-  from pg_depend d join pg_class c on c.oid = d.objid
-    join pg_extension e on e.oid = d.refobjid
-  where d.classid = 'pg_class'::regclass
-    and c.relname in ('at_reb_dep_expr', 'at_reb_dep_key')
-    and d.refclassid = 'pg_extension'::regclass
-  order by c.relname;
-     relname     | deptype | extname 
------------------+---------+---------
- at_reb_dep_expr | x       | plpgsql
- at_reb_dep_key  | x       | plpgsql
-(2 rows)
-
-drop table at_reb_dep;
 -- Per-column statistics targets should still exist after an ALTER COLUMN TYPE
 create table at_reb_plain (id int not null, val int not null);
 create index at_reb_plain_expr on at_reb_plain ((val + 1));
 create unique index at_reb_plain_u on at_reb_plain ((id + 0), (val + 0));
+alter table at_reb_plain add constraint at_reb_plain_c unique (id, val);
 alter index at_reb_plain_expr alter column 1 set statistics 321;
 alter index at_reb_plain_u alter column 1 set statistics 111;
 alter index at_reb_plain_u alter column 2 set statistics 222;
+alter index at_reb_plain_expr depends on extension plpgsql;
+alter index at_reb_plain_c depends on extension plpgsql;
 alter table at_reb_plain alter column val type bigint;
 select c.relname, a.attnum, a.attstattarget
   from pg_attribute a join pg_class c on c.oid = a.attrelid
@@ -2365,6 +2374,19 @@ select c.relname, a.attnum, a.attstattarget
  at_reb_plain_u    |      2 |           222
 (3 rows)
 
+select c.relname, d.deptype, e.extname
+  from pg_depend d join pg_class c on c.oid = d.objid
+    join pg_extension e on e.oid = d.refobjid
+  where d.classid = 'pg_class'::regclass
+    and c.relname in ('at_reb_plain_expr', 'at_reb_plain_c')
+    and d.refclassid = 'pg_extension'::regclass
+  order by c.relname;
+      relname      | deptype | extname 
+-------------------+---------+---------
+ at_reb_plain_c    | x       | plpgsql
+ at_reb_plain_expr | x       | plpgsql
+(2 rows)
+
 drop table at_reb_plain;
 -- SET EXPRESSION rebuilds indexes through the same path.
 create table at_reb_set
@@ -2381,6 +2403,162 @@ select c.relname, a.attnum, a.attstattarget
  at_reb_set_idx |      1 |           654
 (1 row)
 
+drop table at_reb_set;
+-- Partitioned tables' descendant indexes 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;
+-- A descendant index can use a different tablespace from its parent index.
+alter index at_reb_expr_leaf set tablespace pg_default;
+-- Load the descendant 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 'descendant expr index comment';
+comment on index at_reb_expr_leaf_2 is 'second descendant 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;
+alter index at_reb_expr_leaf depends on extension plpgsql;
+-- Snapshot each catalog row describing the descendant 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 descendant indexes.
+set default_tablespace = 'regress_tblspace';
+alter table at_reb alter column val type int;
+reset default_tablespace;
+-- The descendant'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)
+
+-- Auto-extension dependencies must also survive the rebuild.
+select d.deptype, e.extname
+  from pg_depend d join pg_extension e on e.oid = d.refobjid
+  where d.classid = 'pg_class'::regclass
+    and d.objid = 'at_reb_expr_leaf'::regclass
+    and d.refclassid = 'pg_extension'::regclass;
+ deptype | extname 
+---------+---------
+ x       | plpgsql
+(1 row)
+
+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);
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index d50fbf1df0a..c439ec4b59e 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -1471,6 +1471,27 @@ alter table at_partitioned alter column b type numeric using b::numeric;
 \d at_part_2
 drop table at_partitioned;
 
+-- Preserve explicitly renamed descendant index names across partitioned index
+-- rebuilds, and avoid auto-generating the same name for a missing sibling.
+create schema alter_type_partidx;
+set search_path = alter_type_partidx;
+create table p (id int not null, a int not null) partition by list (id);
+create table p1 partition of p for values in (1);
+create table p2 partition of p for values in (2);
+create index p_idx on only p (a);
+create index tmp on p2 (a);
+alter index p_idx attach partition tmp;
+alter index tmp rename to p1_a_idx;
+alter table p alter column a type bigint;
+select i.indrelid::regclass::text as partition, c.relname as index_name
+from pg_index i
+join pg_class c on c.oid = i.indexrelid
+where i.indexrelid in (select inhrelid from pg_inherits where inhparent = 'p_idx'::regclass)
+order by 1;
+reset search_path;
+drop table alter_type_partidx.p;
+drop schema alter_type_partidx;
+
 -- Alter column type when no table rewrite is required
 -- Also check that comments are preserved
 create table at_partitioned(id int, name varchar(64), unique (id, name))
@@ -1530,33 +1551,29 @@ select conname, obj_description(oid, 'pg_constraint') as desc
 
 -- Don't remove this DROP, it exposes bug #15672
 drop table at_partitioned;
-create table at_reb_dep (id int not null, val int not null);
-create index at_reb_dep_expr on at_reb_dep ((val + 1));
-alter table at_reb_dep add constraint at_reb_dep_key unique (id, val);
-alter index at_reb_dep_expr depends on extension plpgsql;
-alter index at_reb_dep_key depends on extension plpgsql;
-alter table at_reb_dep alter column val type bigint;
-select c.relname, d.deptype, e.extname
-  from pg_depend d join pg_class c on c.oid = d.objid
-    join pg_extension e on e.oid = d.refobjid
-  where d.classid = 'pg_class'::regclass
-    and c.relname in ('at_reb_dep_expr', 'at_reb_dep_key')
-    and d.refclassid = 'pg_extension'::regclass
-  order by c.relname;
-drop table at_reb_dep;
 
 -- Per-column statistics targets should still exist after an ALTER COLUMN TYPE
 create table at_reb_plain (id int not null, val int not null);
 create index at_reb_plain_expr on at_reb_plain ((val + 1));
 create unique index at_reb_plain_u on at_reb_plain ((id + 0), (val + 0));
+alter table at_reb_plain add constraint at_reb_plain_c unique (id, val);
 alter index at_reb_plain_expr alter column 1 set statistics 321;
 alter index at_reb_plain_u alter column 1 set statistics 111;
 alter index at_reb_plain_u alter column 2 set statistics 222;
+alter index at_reb_plain_expr depends on extension plpgsql;
+alter index at_reb_plain_c depends on extension plpgsql;
 alter table at_reb_plain alter column val type bigint;
 select c.relname, a.attnum, a.attstattarget
   from pg_attribute a join pg_class c on c.oid = a.attrelid
   where c.relname in ('at_reb_plain_expr', 'at_reb_plain_u') and a.attnum > 0
   order by c.relname, a.attnum;
+select c.relname, d.deptype, e.extname
+  from pg_depend d join pg_class c on c.oid = d.objid
+    join pg_extension e on e.oid = d.refobjid
+  where d.classid = 'pg_class'::regclass
+    and c.relname in ('at_reb_plain_expr', 'at_reb_plain_c')
+    and d.refclassid = 'pg_extension'::regclass
+  order by c.relname;
 drop table at_reb_plain;
 -- SET EXPRESSION rebuilds indexes through the same path.
 create table at_reb_set
@@ -1570,6 +1587,154 @@ select c.relname, a.attnum, a.attstattarget
   where c.relname = 'at_reb_set_idx' and a.attnum > 0;
 drop table at_reb_set;
 
+-- Partitioned tables' descendant indexes 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;
+-- A descendant index can use a different tablespace from its parent index.
+alter index at_reb_expr_leaf set tablespace pg_default;
+
+-- Load the descendant 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 'descendant expr index comment';
+comment on index at_reb_expr_leaf_2 is 'second descendant 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;
+alter index at_reb_expr_leaf depends on extension plpgsql;
+
+-- Snapshot each catalog row describing the descendant 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 descendant indexes.
+set default_tablespace = 'regress_tblspace';
+alter table at_reb alter column val type int;
+reset default_tablespace;
+
+-- The descendant'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;
+
+-- Auto-extension dependencies must also survive the rebuild.
+select d.deptype, e.extname
+  from pg_depend d join pg_extension e on e.oid = d.refobjid
+  where d.classid = 'pg_class'::regclass
+    and d.objid = 'at_reb_expr_leaf'::regclass
+    and d.refclassid = 'pg_extension'::regclass;
+
+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 5596591509c..5971031b0c7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2215,6 +2215,7 @@ PartitionDirectoryEntry
 PartitionDispatch
 PartitionElem
 PartitionHashBound
+PartitionIndexProps
 PartitionKey
 PartitionListValue
 PartitionMap
-- 
2.50.1

