From 715b0b12fd3d188a9bded517a5e61c3479a4c1fb Mon Sep 17 00:00:00 2001
From: Alexandra Wang <alexandra.wang.oss@gmail.com>
Date: Mon, 3 Aug 2026 15:59:52 -0700
Subject: [PATCH v9 4/4] Improve auto-generated names for extended statistics

When CREATE STATISTICS is used without an explicit name, the name is
generated from the columns and expressions the object is defined on.
Previously only a bare, unqualified column reference contributed its
name; a column written table-qualified (t.a) or wrapped in parentheses
((a)) was treated as an expression and contributed a fixed "expr".

This was especially unhelpful for join statistics, whose columns are
written table-qualified: an unnamed object on (t1.b, t1.c, t2.e, t2.f)
came out as t1_expr_expr_expr_expr_stat, saying nothing about the
columns involved.

Resolve a simple column reference to its column name regardless of how
it was written, so a column written "(a)" or "t.a" contributes "a"
just as "a" does; a more complex expression still contributes "expr".
For a join statistics object each column is additionally qualified
with its relation name, since a bare column name would not indicate
which relation it came from.

The join object above is now named t1_b_t1_c_t2_e_t2_f_stat.
Single-table names change too where a column was parenthesized or
table-qualified: for example "CREATE STATISTICS ON (a), (b) FROM t" is
now t_a_b_stat instead of t_expr_expr_stat.

Reported-by: Tomas Vondra <tomas@vondra.me>
Discussion: https://postgr.es/m/02dd92ff-b1ee-4e69-9f7c-abc60dfb4142@vondra.me
---
 src/backend/commands/statscmds.c              | 62 ++++++++++++++++---
 src/test/regress/expected/stats_ext.out       | 25 ++++++++
 .../regress/expected/stats_ext_crossrel.out   | 23 +++++++
 src/test/regress/sql/stats_ext.sql            | 24 +++++++
 src/test/regress/sql/stats_ext_crossrel.sql   | 22 +++++++
 5 files changed, 148 insertions(+), 8 deletions(-)

diff --git a/src/backend/commands/statscmds.c b/src/backend/commands/statscmds.c
index bbaef34ed8d..ab00c99917f 100644
--- a/src/backend/commands/statscmds.c
+++ b/src/backend/commands/statscmds.c
@@ -46,7 +46,8 @@
 
 static char *ChooseExtendedStatisticName(const char *name1, const char *name2,
 										 const char *label, Oid namespaceid);
-static char *ChooseExtendedStatisticNameAddition(List *exprs);
+static char *ChooseExtendedStatisticNameAddition(List *exprs, Oid relid,
+												 List *joinrels);
 
 /*
  *		CREATE STATISTICS
@@ -500,10 +501,25 @@ CreateStatistics(CreateStatsStmt *stmt, bool check_rights)
 	else
 	{
 		namespaceId = RelationGetNamespace(rel);
-		namestr = ChooseExtendedStatisticName(RelationGetRelationName(rel),
-											  ChooseExtendedStatisticNameAddition(stmt->exprs),
-											  "stat",
-											  namespaceId);
+
+		/*
+		 * Join stats have no single relation to use as a name prefix, so the
+		 * qualified column list is the whole name.
+		 */
+		if (isjoin)
+			namestr = ChooseExtendedStatisticName(ChooseExtendedStatisticNameAddition(stmt->exprs,
+																					 relid,
+																					 stmt->stxjoinrels),
+												  NULL,
+												  "stat",
+												  namespaceId);
+		else
+			namestr = ChooseExtendedStatisticName(RelationGetRelationName(rel),
+												  ChooseExtendedStatisticNameAddition(stmt->exprs,
+																					  relid,
+																					  NIL),
+												  "stat",
+												  namespaceId);
 	}
 	namestrcpy(&stxname, namestr);
 
@@ -1161,6 +1177,10 @@ ChooseExtendedStatisticName(const char *name1, const char *name2,
  * names for it.  This will be passed to ChooseExtendedStatisticName along
  * with the parent table name and a suitable label.
  *
+ * For a join statistics object (joinrels != NIL) each column is qualified with
+ * its relation name and the result is used as "name1" instead, since a bare
+ * column name would not identify which relation it came from.
+ *
  * We know that less than NAMEDATALEN characters will actually be used,
  * so we can truncate the result once we've generated that many.
  *
@@ -1168,7 +1188,7 @@ ChooseExtendedStatisticName(const char *name1, const char *name2,
  * ChooseIndexNameAddition.
  */
 static char *
-ChooseExtendedStatisticNameAddition(List *exprs)
+ChooseExtendedStatisticNameAddition(List *exprs, Oid relid, List *joinrels)
 {
 	char		buf[NAMEDATALEN * 2];
 	int			buflen = 0;
@@ -1178,6 +1198,7 @@ ChooseExtendedStatisticNameAddition(List *exprs)
 	foreach(lc, exprs)
 	{
 		StatsElem  *selem = (StatsElem *) lfirst(lc);
+		char		qualbuf[NAMEDATALEN * 2];
 		const char *name;
 
 		/* It should be one of these, but just skip if it happens not to be */
@@ -1186,8 +1207,30 @@ ChooseExtendedStatisticNameAddition(List *exprs)
 
 		name = selem->name;
 
-		if (buflen > 0)
-			buf[buflen++] = '_';	/* insert _ between names */
+		/*
+		 * A simple column reference resolves to a bare Var; use the column
+		 * name, qualified with the relation name for a join object.
+		 */
+		if (!name && selem->expr && IsA(selem->expr, Var))
+		{
+			Var		   *var = (Var *) selem->expr;
+			Oid			colrelid = joinrels ? list_nth_oid(joinrels, var->varno - 1) : relid;
+
+			name = get_attname(colrelid, var->varattno, true);
+
+			if (name && joinrels)
+			{
+				char	   *relname = get_rel_name(colrelid);
+
+				if (relname)
+				{
+					snprintf(qualbuf, sizeof(qualbuf), "%s_%s", relname, name);
+					name = qualbuf;
+				}
+				else
+					name = NULL;	/* defensive: fall back to "expr" */
+			}
+		}
 
 		/*
 		 * We use fixed 'expr' for expressions, which have empty column names.
@@ -1198,6 +1241,9 @@ ChooseExtendedStatisticNameAddition(List *exprs)
 		if (!name)
 			name = "expr";
 
+		if (buflen > 0)
+			buf[buflen++] = '_';	/* insert _ between names */
+
 		/*
 		 * At this point we have buflen <= NAMEDATALEN.  name should be less
 		 * than NAMEDATALEN already, but use strlcpy for paranoia.
diff --git a/src/test/regress/expected/stats_ext.out b/src/test/regress/expected/stats_ext.out
index c6a52b71d57..8744f5cdbf0 100644
--- a/src/test/regress/expected/stats_ext.out
+++ b/src/test/regress/expected/stats_ext.out
@@ -3840,3 +3840,28 @@ SELECT * FROM check_estimated_rows('SELECT COUNT(*) FROM declared_order_grouping
 (1 row)
 
 DROP TABLE declared_order_grouping;
+--
+-- Auto-generated statistics object names.
+--
+-- A simple column reference contributes the column name, whether written
+-- unqualified, table-qualified, or wrapped in parens; a more complex expression
+-- contributes a fixed "expr".
+CREATE TABLE auto_t1 (a INTEGER, b INTEGER, c INTEGER);
+-- Unqualified columns: bare column names.
+CREATE STATISTICS ON b, c FROM auto_t1;
+-- Table-qualified columns: still the column names.
+CREATE STATISTICS ON auto_t1.a, auto_t1.b FROM auto_t1;
+-- A parenthesized column and a more complex expression: the column resolves
+-- to its name, the expression uses the fixed "expr".
+CREATE STATISTICS ON (a), (b + c) FROM auto_t1;
+SELECT stxname FROM pg_statistic_ext
+    WHERE stxrelid = 'auto_t1'::regclass
+    ORDER BY stxname;
+       stxname       
+---------------------
+ auto_t1_a_b_stat
+ auto_t1_a_expr_stat
+ auto_t1_b_c_stat
+(3 rows)
+
+DROP TABLE auto_t1;
diff --git a/src/test/regress/expected/stats_ext_crossrel.out b/src/test/regress/expected/stats_ext_crossrel.out
index 63db40f1974..3aab6da7306 100644
--- a/src/test/regress/expected/stats_ext_crossrel.out
+++ b/src/test/regress/expected/stats_ext_crossrel.out
@@ -2058,3 +2058,26 @@ DROP OPERATOR <<< (int4, int4);
 DROP FUNCTION op_leak(int, int);
 DROP TABLE leak_anchor, leak_dim;
 DROP ROLE regress_join_leak_user;
+--
+-- Auto-generated statistics object name for a join statistics object.
+--
+-- A join statistics object qualifies each column with its relation name, since
+-- a bare column name would not say which relation it came from.  (Single-table
+-- naming is covered in stats_ext.)
+CREATE TABLE auto_t1 (a INTEGER, b INTEGER, c INTEGER);
+CREATE TABLE auto_t2 (d INTEGER, e INTEGER, f INTEGER);
+CREATE INDEX ON auto_t1 (a);
+CREATE INDEX ON auto_t2 (d);
+-- Every column is qualified with its relation, including two columns from the
+-- same relation.
+CREATE STATISTICS (mcv) ON auto_t1.b, auto_t1.c, auto_t2.e, auto_t2.f
+    FROM auto_t1 JOIN auto_t2 ON (auto_t1.a = auto_t2.d);
+SELECT stxname FROM pg_statistic_ext
+    WHERE stxrelid IN ('auto_t1'::regclass, 'auto_t2'::regclass)
+    ORDER BY stxname;
+                   stxname                    
+----------------------------------------------
+ auto_t1_b_auto_t1_c_auto_t2_e_auto_t2_f_stat
+(1 row)
+
+DROP TABLE auto_t1, auto_t2;
diff --git a/src/test/regress/sql/stats_ext.sql b/src/test/regress/sql/stats_ext.sql
index 33844520303..41493300c3d 100644
--- a/src/test/regress/sql/stats_ext.sql
+++ b/src/test/regress/sql/stats_ext.sql
@@ -1983,3 +1983,27 @@ ANALYZE declared_order_grouping;
 -- estimate should track the 100 distinct groups, not ndistinct(a) * ndistinct(b)
 SELECT * FROM check_estimated_rows('SELECT COUNT(*) FROM declared_order_grouping GROUP BY a, b');
 DROP TABLE declared_order_grouping;
+
+--
+-- Auto-generated statistics object names.
+--
+-- A simple column reference contributes the column name, whether written
+-- unqualified, table-qualified, or wrapped in parens; a more complex expression
+-- contributes a fixed "expr".
+CREATE TABLE auto_t1 (a INTEGER, b INTEGER, c INTEGER);
+
+-- Unqualified columns: bare column names.
+CREATE STATISTICS ON b, c FROM auto_t1;
+
+-- Table-qualified columns: still the column names.
+CREATE STATISTICS ON auto_t1.a, auto_t1.b FROM auto_t1;
+
+-- A parenthesized column and a more complex expression: the column resolves
+-- to its name, the expression uses the fixed "expr".
+CREATE STATISTICS ON (a), (b + c) FROM auto_t1;
+
+SELECT stxname FROM pg_statistic_ext
+    WHERE stxrelid = 'auto_t1'::regclass
+    ORDER BY stxname;
+
+DROP TABLE auto_t1;
diff --git a/src/test/regress/sql/stats_ext_crossrel.sql b/src/test/regress/sql/stats_ext_crossrel.sql
index 8fa504044fd..7573dce7fff 100644
--- a/src/test/regress/sql/stats_ext_crossrel.sql
+++ b/src/test/regress/sql/stats_ext_crossrel.sql
@@ -1480,3 +1480,25 @@ DROP OPERATOR <<< (int4, int4);
 DROP FUNCTION op_leak(int, int);
 DROP TABLE leak_anchor, leak_dim;
 DROP ROLE regress_join_leak_user;
+
+--
+-- Auto-generated statistics object name for a join statistics object.
+--
+-- A join statistics object qualifies each column with its relation name, since
+-- a bare column name would not say which relation it came from.  (Single-table
+-- naming is covered in stats_ext.)
+CREATE TABLE auto_t1 (a INTEGER, b INTEGER, c INTEGER);
+CREATE TABLE auto_t2 (d INTEGER, e INTEGER, f INTEGER);
+CREATE INDEX ON auto_t1 (a);
+CREATE INDEX ON auto_t2 (d);
+
+-- Every column is qualified with its relation, including two columns from the
+-- same relation.
+CREATE STATISTICS (mcv) ON auto_t1.b, auto_t1.c, auto_t2.e, auto_t2.f
+    FROM auto_t1 JOIN auto_t2 ON (auto_t1.a = auto_t2.d);
+
+SELECT stxname FROM pg_statistic_ext
+    WHERE stxrelid IN ('auto_t1'::regclass, 'auto_t2'::regclass)
+    ORDER BY stxname;
+
+DROP TABLE auto_t1, auto_t2;
-- 
2.50.1 (Apple Git-155)

