From eb099b2b0c341995da51ff584aa2394cad37971c Mon Sep 17 00:00:00 2001
From: Roberto Mello <roberto.mello@gmail.com>
Date: Tue, 24 Mar 2026 19:20:10 -0600
Subject: [PATCH v3] Fix pg_publication_tables to return NULL attnames for
 all-column publications

Previously, pg_get_publication_tables() synthesized a column list even
when no explicit column list was specified in the publication.  This made
it impossible for consumers of pg_publication_tables to distinguish
between "all columns are published" (new columns will automatically be
replicated) and an explicit column list (only listed columns are
replicated).

Worse, the subscriber-side workaround in fetch_remote_table_info()
compared array_length(gpt.attrs, 1) against pg_class.relnatts to detect
the "all columns" case, but relnatts includes dropped columns while the
synthesized list excluded them, so tables with dropped columns were
misidentified as having an explicit column list.

Fix by simply leaving attrs as NULL when no column list was specified
(prattrs is NULL) in the publication catalog.  This is consistent with
how prattrs itself is stored and removes the need for the relnatts
heuristic in tablesync.c.

The real replication column filtering (which must account for dropped
columns, generated columns, and pub->pubgencols_type) is performed
downstream in pgoutput.c by pub_form_cols_map() and
check_and_fetch_column_list(), both of which are unchanged by this
patch.
---
 doc/src/sgml/system-views.sgml              |  9 ++-
 src/backend/catalog/pg_publication.c        | 52 +++---------
 src/backend/replication/logical/tablesync.c |  9 +--
 src/test/regress/expected/publication.out   | 90 +++++++++++++++++++--
 src/test/regress/sql/publication.sql        | 59 ++++++++++++++
 5 files changed, 160 insertions(+), 59 deletions(-)

diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 9ee1a2bfc6a..6c926860e41 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -2712,9 +2712,12 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
        (references <link linkend="catalog-pg-attribute"><structname>pg_attribute</structname></link>.<structfield>attname</structfield>)
       </para>
       <para>
-       Names of table columns included in the publication. This contains all
-       the columns of the table when the user didn't specify the column list
-       for the table.
+       Names of the table columns included in the publication, or NULL if
+       no explicit column list was specified for the table.  When NULL, all
+       current and future columns of the table are published; new columns
+       added to the table will automatically be replicated.  When non-NULL,
+       only the listed columns are replicated, and newly added columns will
+       not appear until the publication is explicitly altered.
       </para></entry>
      </row>
 
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index c92ff3f51c3..a2fa4906f08 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1439,49 +1439,15 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 			nulls[3] = true;
 		}
 
-		/* Show all columns when the column list is not specified. */
-		if (nulls[2])
-		{
-			Relation	rel = table_open(relid, AccessShareLock);
-			int			nattnums = 0;
-			int16	   *attnums;
-			TupleDesc	desc = RelationGetDescr(rel);
-			int			i;
-
-			attnums = palloc_array(int16, desc->natts);
-
-			for (i = 0; i < desc->natts; i++)
-			{
-				Form_pg_attribute att = TupleDescAttr(desc, i);
-
-				if (att->attisdropped)
-					continue;
-
-				if (att->attgenerated)
-				{
-					/* We only support replication of STORED generated cols. */
-					if (att->attgenerated != ATTRIBUTE_GENERATED_STORED)
-						continue;
-
-					/*
-					 * User hasn't requested to replicate STORED generated
-					 * cols.
-					 */
-					if (pub->pubgencols_type != PUBLISH_GENCOLS_STORED)
-						continue;
-				}
-
-				attnums[nattnums++] = att->attnum;
-			}
-
-			if (nattnums > 0)
-			{
-				values[2] = PointerGetDatum(buildint2vector(attnums, nattnums));
-				nulls[2] = false;
-			}
-
-			table_close(rel, AccessShareLock);
-		}
+		/*
+		 * When no column list is specified (prattrs is NULL), we leave
+		 * attrs as NULL rather than synthesizing a list of all current
+		 * columns.  This allows consumers of pg_publication_tables to
+		 * distinguish between "all columns are published" (attrs IS
+		 * NULL -- new columns will automatically be replicated) and an
+		 * explicit column list (attrs IS NOT NULL -- only listed columns
+		 * are replicated).
+		 */
 
 		rettuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
 
diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c
index f49a4852ecb..5f6d892e595 100644
--- a/src/backend/replication/logical/tablesync.c
+++ b/src/backend/replication/logical/tablesync.c
@@ -799,13 +799,10 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
 		 */
 		resetStringInfo(&cmd);
 		appendStringInfo(&cmd,
-						 "SELECT DISTINCT"
-						 "  (CASE WHEN (array_length(gpt.attrs, 1) = c.relnatts)"
-						 "   THEN NULL ELSE gpt.attrs END)"
+						 "SELECT DISTINCT gpt.attrs"
 						 "  FROM pg_publication p,"
-						 "  LATERAL pg_get_publication_tables(p.pubname) gpt,"
-						 "  pg_class c"
-						 " WHERE gpt.relid = %u AND c.oid = gpt.relid"
+						 "  LATERAL pg_get_publication_tables(p.pubname) gpt"
+						 " WHERE gpt.relid = %u"
 						 "   AND p.pubname IN ( %s )",
 						 lrel->remoteid,
 						 pub_names->data);
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index a220f48b285..4a25e9654f3 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -2072,7 +2072,7 @@ CREATE PUBLICATION pub FOR TABLES IN SCHEMA sch2 WITH (PUBLISH_VIA_PARTITION_ROO
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename  | attnames | rowfilter 
 ---------+------------+------------+----------+-----------
- pub     | sch2       | tbl1_part1 | {a}      | 
+ pub     | sch2       | tbl1_part1 |          | 
 (1 row)
 
 DROP PUBLICATION pub;
@@ -2081,7 +2081,7 @@ CREATE PUBLICATION pub FOR TABLE sch2.tbl1_part1 WITH (PUBLISH_VIA_PARTITION_ROO
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename  | attnames | rowfilter 
 ---------+------------+------------+----------+-----------
- pub     | sch2       | tbl1_part1 | {a}      | 
+ pub     | sch2       | tbl1_part1 |          | 
 (1 row)
 
 -- Table publication that includes both the parent table and the child table
@@ -2089,7 +2089,7 @@ ALTER PUBLICATION pub ADD TABLE sch1.tbl1;
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename | attnames | rowfilter 
 ---------+------------+-----------+----------+-----------
- pub     | sch1       | tbl1      | {a}      | 
+ pub     | sch1       | tbl1      |          | 
 (1 row)
 
 DROP PUBLICATION pub;
@@ -2098,7 +2098,7 @@ CREATE PUBLICATION pub FOR TABLES IN SCHEMA sch2 WITH (PUBLISH_VIA_PARTITION_ROO
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename  | attnames | rowfilter 
 ---------+------------+------------+----------+-----------
- pub     | sch2       | tbl1_part1 | {a}      | 
+ pub     | sch2       | tbl1_part1 |          | 
 (1 row)
 
 DROP PUBLICATION pub;
@@ -2107,7 +2107,7 @@ CREATE PUBLICATION pub FOR TABLE sch2.tbl1_part1 WITH (PUBLISH_VIA_PARTITION_ROO
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename  | attnames | rowfilter 
 ---------+------------+------------+----------+-----------
- pub     | sch2       | tbl1_part1 | {a}      | 
+ pub     | sch2       | tbl1_part1 |          | 
 (1 row)
 
 -- Table publication that includes both the parent table and the child table
@@ -2115,7 +2115,7 @@ ALTER PUBLICATION pub ADD TABLE sch1.tbl1;
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename  | attnames | rowfilter 
 ---------+------------+------------+----------+-----------
- pub     | sch2       | tbl1_part1 | {a}      | 
+ pub     | sch2       | tbl1_part1 |          | 
 (1 row)
 
 DROP PUBLICATION pub;
@@ -2130,7 +2130,7 @@ CREATE PUBLICATION pub FOR TABLES IN SCHEMA sch1 WITH (PUBLISH_VIA_PARTITION_ROO
 SELECT * FROM pg_publication_tables;
  pubname | schemaname | tablename | attnames | rowfilter 
 ---------+------------+-----------+----------+-----------
- pub     | sch1       | tbl1      | {a}      | 
+ pub     | sch1       | tbl1      |          | 
 (1 row)
 
 RESET client_min_messages;
@@ -2139,6 +2139,82 @@ DROP TABLE sch1.tbl1;
 DROP SCHEMA sch1 cascade;
 DROP SCHEMA sch2 cascade;
 -- ======================================================
+-- Test that pg_publication_tables distinguishes between tables with
+-- an explicit column list and tables without one (attnames should be
+-- NULL when no column list was specified).
+CREATE TABLE pub_col_test (id int, name text, status text);
+CREATE PUBLICATION pub_nocols FOR TABLE pub_col_test;
+CREATE PUBLICATION pub_cols FOR TABLE pub_col_test (id, name);
+-- Without column list: attnames should be NULL
+SELECT pubname, attnames IS NULL AS all_columns FROM pg_publication_tables
+  WHERE tablename = 'pub_col_test' ORDER BY pubname;
+  pubname   | all_columns 
+------------+-------------
+ pub_cols   | f
+ pub_nocols | t
+(2 rows)
+
+-- With column list: attnames should list specific columns
+SELECT pubname, attnames FROM pg_publication_tables
+  WHERE tablename = 'pub_col_test' AND attnames IS NOT NULL ORDER BY pubname;
+ pubname  | attnames  
+----------+-----------
+ pub_cols | {id,name}
+(1 row)
+
+DROP PUBLICATION pub_nocols;
+DROP PUBLICATION pub_cols;
+DROP TABLE pub_col_test;
+-- Test that a table with a dropped column still shows attnames as NULL
+-- when no explicit column list was specified. The old implementation
+-- compared the synthesized column count against relnatts, but relnatts
+-- includes dropped columns, so the heuristic was wrong for this case.
+CREATE TABLE pub_dropped_test (id int, dropped_col text, name text);
+ALTER TABLE pub_dropped_test DROP COLUMN dropped_col;
+CREATE PUBLICATION pub_dropped FOR TABLE pub_dropped_test;
+SELECT pubname, attnames IS NULL AS all_columns FROM pg_publication_tables
+  WHERE tablename = 'pub_dropped_test';
+   pubname   | all_columns 
+-------------+-------------
+ pub_dropped | t
+(1 row)
+
+DROP PUBLICATION pub_dropped;
+DROP TABLE pub_dropped_test;
+-- Test that two publications on the same table, one without a column list
+-- and one with an explicit list of all columns, produce distinguishable
+-- rows in pg_publication_tables. On the subscriber side this difference
+-- would now correctly trigger "cannot use different column lists" during
+-- tablesync.
+CREATE TABLE pub_conflict_test (id int, name text);
+CREATE PUBLICATION pub_all FOR TABLE pub_conflict_test;
+CREATE PUBLICATION pub_explicit FOR TABLE pub_conflict_test (id, name);
+-- The two publications should produce different attnames values:
+-- pub_all -> NULL (all current and future columns)
+-- pub_explicit -> {id,name} (only these columns)
+SELECT pubname, attnames, attnames IS NULL AS is_all_columns
+  FROM pg_publication_tables
+  WHERE tablename = 'pub_conflict_test' ORDER BY pubname;
+   pubname    | attnames  | is_all_columns 
+--------------+-----------+----------------
+ pub_all      |           | t
+ pub_explicit | {id,name} | f
+(2 rows)
+
+-- Confirm they are DISTINCT (2 rows, not 1), which is what causes the
+-- subscriber-side conflict check to fire.
+SELECT COUNT(DISTINCT attnames IS NULL) AS distinct_attnames_nullability
+  FROM pg_publication_tables
+  WHERE tablename = 'pub_conflict_test';
+ distinct_attnames_nullability 
+-------------------------------
+                             2
+(1 row)
+
+DROP PUBLICATION pub_all;
+DROP PUBLICATION pub_explicit;
+DROP TABLE pub_conflict_test;
+-- ======================================================
 -- Test the 'publish_generated_columns' parameter with the following values:
 -- 'stored', 'none'.
 SET client_min_messages = 'ERROR';
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index 22e0a30b5c7..b89cd416b17 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -1327,6 +1327,65 @@ DROP SCHEMA sch1 cascade;
 DROP SCHEMA sch2 cascade;
 -- ======================================================
 
+-- Test that pg_publication_tables distinguishes between tables with
+-- an explicit column list and tables without one (attnames should be
+-- NULL when no column list was specified).
+CREATE TABLE pub_col_test (id int, name text, status text);
+CREATE PUBLICATION pub_nocols FOR TABLE pub_col_test;
+CREATE PUBLICATION pub_cols FOR TABLE pub_col_test (id, name);
+
+-- Without column list: attnames should be NULL
+SELECT pubname, attnames IS NULL AS all_columns FROM pg_publication_tables
+  WHERE tablename = 'pub_col_test' ORDER BY pubname;
+
+-- With column list: attnames should list specific columns
+SELECT pubname, attnames FROM pg_publication_tables
+  WHERE tablename = 'pub_col_test' AND attnames IS NOT NULL ORDER BY pubname;
+
+DROP PUBLICATION pub_nocols;
+DROP PUBLICATION pub_cols;
+DROP TABLE pub_col_test;
+
+-- Test that a table with a dropped column still shows attnames as NULL
+-- when no explicit column list was specified.  The old implementation
+-- compared the synthesized column count against relnatts, but relnatts
+-- includes dropped columns, so the heuristic was wrong for this case.
+CREATE TABLE pub_dropped_test (id int, dropped_col text, name text);
+ALTER TABLE pub_dropped_test DROP COLUMN dropped_col;
+CREATE PUBLICATION pub_dropped FOR TABLE pub_dropped_test;
+
+SELECT pubname, attnames IS NULL AS all_columns FROM pg_publication_tables
+  WHERE tablename = 'pub_dropped_test';
+
+DROP PUBLICATION pub_dropped;
+DROP TABLE pub_dropped_test;
+
+-- Test that two publications on the same table, one without a column list
+-- and one with an explicit list of all columns, produce distinguishable
+-- rows in pg_publication_tables. On the subscriber side this difference would
+-- now correctly trigger "cannot use different column lists" during tablesync.
+CREATE TABLE pub_conflict_test (id int, name text);
+CREATE PUBLICATION pub_all FOR TABLE pub_conflict_test;
+CREATE PUBLICATION pub_explicit FOR TABLE pub_conflict_test (id, name);
+
+-- The two publications should produce different attnames values:
+-- pub_all -> NULL (all current and future columns)
+-- pub_explicit -> {id,name} (only these columns)
+SELECT pubname, attnames, attnames IS NULL AS is_all_columns
+  FROM pg_publication_tables
+  WHERE tablename = 'pub_conflict_test' ORDER BY pubname;
+
+-- Confirm they are DISTINCT (2 rows, not 1), which is what causes the
+-- subscriber-side conflict check to fire.
+SELECT COUNT(DISTINCT attnames IS NULL) AS distinct_attnames_nullability
+  FROM pg_publication_tables
+  WHERE tablename = 'pub_conflict_test';
+
+DROP PUBLICATION pub_all;
+DROP PUBLICATION pub_explicit;
+DROP TABLE pub_conflict_test;
+-- ======================================================
+
 -- Test the 'publish_generated_columns' parameter with the following values:
 -- 'stored', 'none'.
 SET client_min_messages = 'ERROR';
-- 
2.50.1 (Apple Git-155)

