From 615e99ebc45adc614366fda80f5d9d75cbaf6ea8 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Wed, 12 Aug 2026 16:06:45 +0000
Subject: [PATCH 2/2] Preserve statistics targets of indexes rebuilt by ALTER
 TABLE

When ALTER TABLE ALTER COLUMN TYPE (or SET EXPRESSION) recreates the
indexes that depend on the altered column, it replays each index's
definition string through CREATE INDEX.  A statistics target set on an
expression column with ALTER INDEX ... ALTER COLUMN ... SET STATISTICS
is not part of that definition string, so the target silently reverted
to the default after the rebuild.  The index of a rebuilt exclusion
constraint lost its targets the same way.

To fix, capture the per-column targets next to where
ATPostAlterTypeParse() already captures the index's comment, carry them
in a new IndexStmt field, and have DefineIndex() apply them once the
index exists.  DefineIndex() also propagates the targets to the indexes
it creates on partitions, matching the recursion that ALTER INDEX ...
SET STATISTICS performs on partitioned indexes.  Targets set directly
on a partition's index are still not preserved when the rebuild
recurses from the parent, mirroring the pre-existing behavior for such
indexes' comments.
---
 src/backend/commands/indexcmds.c          | 70 +++++++++++++++++++++++
 src/backend/commands/tablecmds.c          | 60 +++++++++++++++++++
 src/include/nodes/parsenodes.h            |  1 +
 src/test/regress/expected/alter_table.out | 36 ++++++++++++
 src/test/regress/sql/alter_table.sql      | 26 +++++++++
 5 files changed, 193 insertions(+)

diff --git a/src/backend/commands/indexcmds.c b/src/backend/commands/indexcmds.c
index 5d62fa7605b..d2b826e6b66 100644
--- a/src/backend/commands/indexcmds.c
+++ b/src/backend/commands/indexcmds.c
@@ -29,6 +29,7 @@
 #include "catalog/indexing.h"
 #include "catalog/namespace.h"
 #include "catalog/pg_am.h"
+#include "catalog/pg_attribute.h"
 #include "catalog/pg_authid.h"
 #include "catalog/pg_collation.h"
 #include "catalog/pg_constraint.h"
@@ -83,6 +84,7 @@ typedef struct CIEN_context
 /* non-export function prototypes */
 static bool CompareOpclassOptions(const Datum *opts1, const Datum *opts2, int natts);
 static void CheckPredicate(Expr *predicate);
+static void SetIndexStatTargets(Oid indexOid, List *stattargets);
 static void ComputeIndexAttrs(ParseState *pstate,
 							  IndexInfo *indexInfo,
 							  Oid *typeOids,
@@ -1324,6 +1326,10 @@ DefineIndex(ParseState *pstate,
 		CreateComments(indexRelationId, RelationRelationId, 0,
 					   stmt->idxcomment);
 
+	/* Apply any requested per-column statistics targets */
+	if (stmt->stattargets != NIL)
+		SetIndexStatTargets(indexRelationId, stmt->stattargets);
+
 	if (partitioned)
 	{
 		PartitionDesc partdesc;
@@ -1546,6 +1552,13 @@ DefineIndex(ParseState *pstate,
 														attmap,
 														NULL);
 
+					/*
+					 * Propagate any per-column statistics targets to the
+					 * child index, matching the recursion that ALTER INDEX
+					 * ... SET STATISTICS performs on partitioned indexes.
+					 */
+					childStmt->stattargets = stmt->stattargets;
+
 					/*
 					 * Recurse as the starting user ID.  Callee will use that
 					 * for permission checks, then switch again.
@@ -1864,6 +1877,63 @@ DefineIndex(ParseState *pstate,
 	return address;
 }
 
+/*
+ * SetIndexStatTargets
+ *		Apply per-column statistics targets to a just-created index.
+ *
+ * The list contains one integer per index column, with -1 meaning the
+ * target is not set for that column.  It is captured from a previous
+ * incarnation of the index by ATPostAlterTypeParse(), so the column
+ * structure is known to match.
+ */
+static void
+SetIndexStatTargets(Oid indexOid, List *stattargets)
+{
+	Relation	attrelation;
+	AttrNumber	attnum = 0;
+	ListCell   *lc;
+
+	attrelation = table_open(AttributeRelationId, RowExclusiveLock);
+
+	foreach(lc, stattargets)
+	{
+		int			target = lfirst_int(lc);
+		HeapTuple	tuple,
+					newtuple;
+		Datum		repl_val[Natts_pg_attribute];
+		bool		repl_null[Natts_pg_attribute];
+		bool		repl_repl[Natts_pg_attribute];
+
+		attnum++;
+
+		if (target < 0)
+			continue;
+
+		tuple = SearchSysCache2(ATTNUM,
+								ObjectIdGetDatum(indexOid),
+								Int16GetDatum(attnum));
+		if (!HeapTupleIsValid(tuple))
+			elog(ERROR, "cache lookup failed for attribute %d of relation %u",
+				 attnum, indexOid);
+
+		memset(repl_val, 0, sizeof(repl_val));
+		memset(repl_null, false, sizeof(repl_null));
+		memset(repl_repl, false, sizeof(repl_repl));
+
+		repl_val[Anum_pg_attribute_attstattarget - 1] = Int16GetDatum(target);
+		repl_repl[Anum_pg_attribute_attstattarget - 1] = true;
+
+		newtuple = heap_modify_tuple(tuple, RelationGetDescr(attrelation),
+									 repl_val, repl_null, repl_repl);
+		CatalogTupleUpdate(attrelation, &tuple->t_self, newtuple);
+
+		heap_freetuple(newtuple);
+		ReleaseSysCache(tuple);
+	}
+
+	table_close(attrelation, RowExclusiveLock);
+}
+
 
 /*
  * CheckPredicate
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index 16e89e848d9..e7c294cd0f0 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -699,6 +699,7 @@ static void RememberWholeRowDependentForRebuilding(AlteredTableInfo *tab, AlterT
 static void RememberConstraintForRebuilding(Oid conoid, AlteredTableInfo *tab);
 static void RememberIndexForRebuilding(Oid indoid, AlteredTableInfo *tab);
 static void RememberStatisticsForRebuilding(Oid stxoid, AlteredTableInfo *tab);
+static List *GetIndexStatTargets(Oid indexOid);
 static void ATPostAlterTypeCleanup(List **wqueue, AlteredTableInfo *tab,
 								   LOCKMODE lockmode);
 static void ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, Oid ownerId,
@@ -16361,6 +16362,61 @@ ATPostAlterTypeCleanup(List **wqueue, AlteredTableInfo *tab, LOCKMODE lockmode)
 	 */
 }
 
+/*
+ * Subroutine for ATPostAlterTypeParse().  Fetch the per-column statistics
+ * targets of the given index, as a list of integers with -1 standing for
+ * columns whose target is not set.  Returns NIL if no column has a target,
+ * which is the common case.
+ */
+static List *
+GetIndexStatTargets(Oid indexOid)
+{
+	List	   *result = NIL;
+	bool		found = false;
+	HeapTuple	reltup;
+	int			natts;
+
+	reltup = SearchSysCache1(RELOID, ObjectIdGetDatum(indexOid));
+	if (!HeapTupleIsValid(reltup))
+		elog(ERROR, "cache lookup failed for relation %u", indexOid);
+	natts = ((Form_pg_class) GETSTRUCT(reltup))->relnatts;
+	ReleaseSysCache(reltup);
+
+	for (AttrNumber attnum = 1; attnum <= natts; attnum++)
+	{
+		HeapTuple	tuple;
+		Datum		dat;
+		bool		isnull;
+
+		tuple = SearchSysCache2(ATTNUM,
+								ObjectIdGetDatum(indexOid),
+								Int16GetDatum(attnum));
+		if (!HeapTupleIsValid(tuple))
+			elog(ERROR, "cache lookup failed for attribute %d of relation %u",
+				 attnum, indexOid);
+
+		dat = SysCacheGetAttr(ATTNUM, tuple,
+							  Anum_pg_attribute_attstattarget, &isnull);
+		if (isnull)
+			result = lappend_int(result, -1);
+		else
+		{
+			result = lappend_int(result, (int) DatumGetInt16(dat));
+			found = true;
+		}
+
+		ReleaseSysCache(tuple);
+	}
+
+	if (!found)
+	{
+		list_free(result);
+		return NIL;
+	}
+
+	return result;
+}
+
 /*
  * Parse the previously-saved definition string for a constraint, index or
  * statistics object against the newly-established column data type(s), and
@@ -16453,6 +16509,8 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, Oid ownerId,
 			stmt->reset_default_tblspc = true;
 			/* keep the index's comment */
 			stmt->idxcomment = GetComment(oldId, RelationRelationId, 0);
+			/* keep any per-column statistics targets */
+			stmt->stattargets = GetIndexStatTargets(oldId);
 
 			newcmd = makeNode(AlterTableCmd);
 			newcmd->subtype = AT_ReAddIndex;
@@ -16482,6 +16540,8 @@ ATPostAlterTypeParse(Oid oldId, Oid oldRelId, Oid refRelId, Oid ownerId,
 					/* keep any comment on the index */
 					indstmt->idxcomment = GetComment(indoid,
 													 RelationRelationId, 0);
+					/* keep any per-column statistics targets */
+					indstmt->stattargets = GetIndexStatTargets(indoid);
 					indstmt->reset_default_tblspc = true;
 
 					cmd->subtype = AT_ReAddIndex;
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index b99ac94cb3e..85f487f17ed 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -3641,6 +3641,7 @@ typedef struct IndexStmt
 	Node	   *whereClause;	/* qualification (partial-index predicate) */
 	List	   *excludeOpNames; /* exclusion operator names, or NIL if none */
 	char	   *idxcomment;		/* comment to apply to index, or NULL */
+	List	   *stattargets;	/* per-column statistics targets, or NIL */
 	Oid			indexOid;		/* OID of an existing index, if any */
 	RelFileNumber oldNumber;	/* relfilenumber of existing storage, if any */
 	SubTransactionId oldCreateSubid;	/* rd_createSubid of oldNumber */
diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out
index e167a41ce79..8f9a30d999c 100644
--- a/src/test/regress/expected/alter_table.out
+++ b/src/test/regress/expected/alter_table.out
@@ -119,6 +119,42 @@ HINT:  Alter statistics on table column instead.
 ALTER INDEX attmp_idx ALTER COLUMN 4 SET STATISTICS 1000;
 ERROR:  column number 4 of relation "attmp_idx" does not exist
 ALTER INDEX attmp_idx ALTER COLUMN 2 SET STATISTICS -1;
+DROP TABLE attmp;
+-- Check that index column statistics targets survive the index rebuilds
+-- caused by ALTER TABLE ... ALTER COLUMN TYPE, including the rebuild of
+-- an exclusion constraint's index and recursion to partitions.
+CREATE TABLE attmp (a int, b int, EXCLUDE ((a + b) WITH =));
+CREATE INDEX attmp_expr_idx ON attmp ((a * b));
+ALTER INDEX attmp_expr_idx ALTER COLUMN 1 SET STATISTICS 100;
+ALTER INDEX attmp_a_b_excl ALTER COLUMN 1 SET STATISTICS 200;
+ALTER TABLE attmp ALTER COLUMN a TYPE bigint;
+SELECT c.relname, a.attstattarget
+  FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
+  WHERE c.relname IN ('attmp_expr_idx', 'attmp_a_b_excl') AND a.attnum = 1
+  ORDER BY c.relname;
+    relname     | attstattarget 
+----------------+---------------
+ attmp_a_b_excl |           200
+ attmp_expr_idx |           100
+(2 rows)
+
+DROP TABLE attmp;
+CREATE TABLE attmp (a int, b int) PARTITION BY RANGE (b);
+CREATE TABLE attmp1 PARTITION OF attmp FOR VALUES FROM (0) TO (100);
+CREATE INDEX attmp_part_idx ON attmp ((a + 1));
+ALTER INDEX attmp_part_idx ALTER COLUMN 1 SET STATISTICS 100;
+ALTER TABLE attmp ALTER COLUMN a TYPE bigint;
+SELECT c.relname, a.attstattarget
+  FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
+  WHERE a.attrelid IN ('attmp_part_idx'::regclass, 'attmp1_a_1_idx'::regclass)
+    AND a.attnum = 1
+  ORDER BY c.relname;
+    relname     | attstattarget 
+----------------+---------------
+ attmp1_a_1_idx |           100
+ attmp_part_idx |           100
+(2 rows)
+
 DROP TABLE attmp;
 --
 -- rename - check on both non-temp and temp tables
diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql
index 9f6c2a4bb08..11d931a7b99 100644
--- a/src/test/regress/sql/alter_table.sql
+++ b/src/test/regress/sql/alter_table.sql
@@ -158,6 +158,32 @@ ALTER INDEX attmp_idx ALTER COLUMN 2 SET STATISTICS -1;
 
 DROP TABLE attmp;
 
+-- Check that index column statistics targets survive the index rebuilds
+-- caused by ALTER TABLE ... ALTER COLUMN TYPE, including the rebuild of
+-- an exclusion constraint's index and recursion to partitions.
+CREATE TABLE attmp (a int, b int, EXCLUDE ((a + b) WITH =));
+CREATE INDEX attmp_expr_idx ON attmp ((a * b));
+ALTER INDEX attmp_expr_idx ALTER COLUMN 1 SET STATISTICS 100;
+ALTER INDEX attmp_a_b_excl ALTER COLUMN 1 SET STATISTICS 200;
+ALTER TABLE attmp ALTER COLUMN a TYPE bigint;
+SELECT c.relname, a.attstattarget
+  FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
+  WHERE c.relname IN ('attmp_expr_idx', 'attmp_a_b_excl') AND a.attnum = 1
+  ORDER BY c.relname;
+DROP TABLE attmp;
+
+CREATE TABLE attmp (a int, b int) PARTITION BY RANGE (b);
+CREATE TABLE attmp1 PARTITION OF attmp FOR VALUES FROM (0) TO (100);
+CREATE INDEX attmp_part_idx ON attmp ((a + 1));
+ALTER INDEX attmp_part_idx ALTER COLUMN 1 SET STATISTICS 100;
+ALTER TABLE attmp ALTER COLUMN a TYPE bigint;
+SELECT c.relname, a.attstattarget
+  FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid
+  WHERE a.attrelid IN ('attmp_part_idx'::regclass, 'attmp1_a_1_idx'::regclass)
+    AND a.attnum = 1
+  ORDER BY c.relname;
+DROP TABLE attmp;
+
 
 --
 -- rename - check on both non-temp and temp tables
-- 
2.54.0

