From 7ffa55e77413743b63092a824c1a70f74dd122f0 Mon Sep 17 00:00:00 2001
From: Masahiko Sawada <sawada.mshk@gmail.com>
Date: Fri, 27 Feb 2026 15:42:38 -0800
Subject: [PATCH v2] Avoid full table scans when getting publication table
 information by tablesync workers.

Reported-by: Marcos Pegoraro <marcos@f10.com.br>
Reviewed-by: Zhijie Hou (Fujitsu) <houzj.fnst@fujitsu.com>
Reviewed-by: Matheus Alcantara <matheusssilv97@gmail.com>
Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Discussion: https://postgr.es/m/CAB-JLwbBFNuASyEnZWP0Tck9uNkthBZqi6WoXNevUT6+mV8XmA@mail.gmail.com
---
 src/backend/catalog/pg_publication.c        | 382 +++++++++++++++-----
 src/backend/replication/logical/tablesync.c |  68 +++-
 src/include/catalog/pg_proc.dat             |   9 +
 src/test/regress/expected/publication.out   | 129 +++++++
 src/test/regress/sql/publication.sql        |  67 ++++
 5 files changed, 543 insertions(+), 112 deletions(-)

diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index aadc7c202c6..5213f1d0a23 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -1207,6 +1207,240 @@ GetPublicationByName(const char *pubname, bool missing_ok)
 	return OidIsValid(oid) ? GetPublication(oid) : NULL;
 }
 
+/*
+ * Returns true if the table of the given relid is published for the specified
+ * publication.
+ *
+ * This function evaluates the effective published OID based on the
+ * publish_via_partition_root setting, rather than just checking catalog entries
+ * (e.g., pg_publication_rel). For instance, when publish_via_partition_root is
+ * false, it returns false for a parent partitioned table and true for its leaf
+ * partitions, even if the parent is the one explicitly added to the publication.
+ *
+ * For performance reasons, this function avoids the overhead of constructing
+ * the complete list of published tables during the evaluation. It can execute
+ * quickly even when the publication contains a large number of relations.
+ */
+static bool
+is_table_publishable_in_publication(Oid relid, Publication *pub)
+{
+	if (pub->pubviaroot)
+	{
+		if (pub->alltables)
+		{
+			/*
+			 * ALL TABLE publications with pubviaroot=true include only tables
+			 * that are either regular tables or top-most partitioned tables.
+			 */
+			if (get_rel_relispartition(relid))
+				return false;
+
+			/*
+			 * Check if the table is specified in the EXCEPT clause in the
+			 * publication. ALL TABLE publications have pg_publication_rel
+			 * entries only for EXCEPT'ed tables, so it's sufficient to check
+			 * the existence of its entry.
+			 */
+			return !SearchSysCacheExists2(PUBLICATIONRELMAP,
+										  ObjectIdGetDatum(relid),
+										  ObjectIdGetDatum(pub->oid));
+		}
+
+		/*
+		 * Check if its corresponding entry exists either in
+		 * pg_publication_rel or pg_publication_namespace.
+		 */
+		return (SearchSysCacheExists2(PUBLICATIONRELMAP,
+									  ObjectIdGetDatum(relid),
+									  ObjectIdGetDatum(pub->oid)) ||
+				SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+									  ObjectIdGetDatum(get_rel_namespace(relid)),
+									  ObjectIdGetDatum(pub->oid)));
+	}
+
+	/*
+	 * For non-pubviaroot publications, partitioned table's OID can never be a
+	 * published OID.
+	 */
+	if (get_rel_relkind(relid) == RELKIND_PARTITIONED_TABLE)
+		return false;
+
+	if (pub->alltables)
+	{
+		Oid			target_relid = relid;
+
+		if (get_rel_relispartition(relid))
+		{
+			List	   *ancestors = get_partition_ancestors(relid);
+
+			/*
+			 * Only the top-most ancestor can appear in the EXCEPT clause.
+			 * Therefore, for a partition, exclusion must be evaluated at the
+			 * top-most ancestor.
+			 */
+			target_relid = llast_oid(ancestors);
+
+			list_free(ancestors);
+		}
+
+		/*
+		 * The table is published unless it's specified in the EXCEPT clause.
+		 * ALL TABLE publications have pg_publication_rel entries only for
+		 * EXCEPT'ed tables, so it's sufficient to check the existence of its
+		 * entry.
+		 */
+		return !SearchSysCacheExists2(PUBLICATIONRELMAP,
+									  ObjectIdGetDatum(target_relid),
+									  ObjectIdGetDatum(pub->oid));
+	}
+
+	if (get_rel_relispartition(relid))
+	{
+		List	   *ancestors = get_partition_ancestors(relid);
+		Oid			topmost = GetTopMostAncestorInPublication(pub->oid, ancestors,
+															  NULL);
+
+		list_free(ancestors);
+
+		/* This table is published if its ancestor is published */
+		if (OidIsValid(topmost))
+			return true;
+
+		/* The partition itself might be published, so check below */
+	}
+
+	return (SearchSysCacheExists2(PUBLICATIONRELMAP,
+								  ObjectIdGetDatum(relid),
+								  ObjectIdGetDatum(pub->oid)) ||
+			SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+								  ObjectIdGetDatum(get_rel_namespace(relid)),
+								  ObjectIdGetDatum(pub->oid)));
+}
+
+/*
+ * pg_get_publication_tables() and pg_get_publication_table_info() use
+ * the same record type.
+ */
+#define NUM_PUBLICATION_TABLES_ELEM 4
+
+/*
+ * Construct a tuple descriptor for both pg_get_publication_tales() and
+ * pg_get_publication_table_info() functions.
+ */
+static TupleDesc
+create_published_rel_tuple_desc(void)
+{
+	TupleDesc	tupdesc;
+
+	tupdesc = CreateTemplateTupleDesc(NUM_PUBLICATION_TABLES_ELEM);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 1, "pubid",
+					   OIDOID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 2, "relid",
+					   OIDOID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 3, "attrs",
+					   INT2VECTOROID, -1, 0);
+	TupleDescInitEntry(tupdesc, (AttrNumber) 4, "qual",
+					   PG_NODE_TREEOID, -1, 0);
+
+	return BlessTupleDesc(tupdesc);
+}
+
+/*
+ * Common routine for pg_get_publication_tables() and
+ * pg_get_publication_table_info() to construct the result tuple.
+ * tuple_desc should be the tuple description returned by
+ * create_published_rel_tuple_desc().
+ */
+static HeapTuple
+construct_published_rel_tuple(published_rel *table_info, TupleDesc tuple_desc)
+{
+	Publication *pub;
+	Oid			relid = table_info->relid;
+	Oid			schemaid = get_rel_namespace(relid);
+	HeapTuple	pubtuple = NULL;
+	Datum		values[NUM_PUBLICATION_TABLES_ELEM] = {0};
+	bool		nulls[NUM_PUBLICATION_TABLES_ELEM] = {0};
+
+	pub = GetPublication(table_info->pubid);
+
+	values[0] = ObjectIdGetDatum(pub->oid);
+	values[1] = ObjectIdGetDatum(relid);
+
+	/*
+	 * We don't consider row filters or column lists for FOR ALL TABLES or FOR
+	 * TABLES IN SCHEMA publications.
+	 */
+	if (!pub->alltables &&
+		!SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+							   ObjectIdGetDatum(schemaid),
+							   ObjectIdGetDatum(pub->oid)))
+		pubtuple = SearchSysCacheCopy2(PUBLICATIONRELMAP,
+									   ObjectIdGetDatum(relid),
+									   ObjectIdGetDatum(pub->oid));
+
+	if (HeapTupleIsValid(pubtuple))
+	{
+		/* Lookup the column list attribute. */
+		values[2] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
+									Anum_pg_publication_rel_prattrs,
+									&(nulls[2]));
+
+		/* Null indicates no filter. */
+		values[3] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
+									Anum_pg_publication_rel_prqual,
+									&(nulls[3]));
+	}
+	else
+	{
+		nulls[2] = true;
+		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);
+
+		attnums = palloc_array(int16, desc->natts);
+
+		for (int 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);
+	}
+
+	return heap_form_tuple(tuple_desc, values, nulls);
+}
+
 /*
  * Get information of the tables in the given publication array.
  *
@@ -1215,14 +1449,12 @@ GetPublicationByName(const char *pubname, bool missing_ok)
 Datum
 pg_get_publication_tables(PG_FUNCTION_ARGS)
 {
-#define NUM_PUBLICATION_TABLES_ELEM	4
 	FuncCallContext *funcctx;
 	List	   *table_infos = NIL;
 
 	/* stuff done only on the first call of the function */
 	if (SRF_IS_FIRSTCALL())
 	{
-		TupleDesc	tupdesc;
 		MemoryContext oldcontext;
 		ArrayType  *arr;
 		Datum	   *elems;
@@ -1311,18 +1543,7 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 		if (viaroot)
 			filter_partitions(table_infos);
 
-		/* Construct a tuple descriptor for the result rows. */
-		tupdesc = CreateTemplateTupleDesc(NUM_PUBLICATION_TABLES_ELEM);
-		TupleDescInitEntry(tupdesc, (AttrNumber) 1, "pubid",
-						   OIDOID, -1, 0);
-		TupleDescInitEntry(tupdesc, (AttrNumber) 2, "relid",
-						   OIDOID, -1, 0);
-		TupleDescInitEntry(tupdesc, (AttrNumber) 3, "attrs",
-						   INT2VECTOROID, -1, 0);
-		TupleDescInitEntry(tupdesc, (AttrNumber) 4, "qual",
-						   PG_NODE_TREEOID, -1, 0);
-
-		funcctx->tuple_desc = BlessTupleDesc(tupdesc);
+		funcctx->tuple_desc = create_published_rel_tuple_desc();
 		funcctx->user_fctx = table_infos;
 
 		MemoryContextSwitchTo(oldcontext);
@@ -1334,99 +1555,74 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
 
 	if (funcctx->call_cntr < list_length(table_infos))
 	{
-		HeapTuple	pubtuple = NULL;
 		HeapTuple	rettuple;
-		Publication *pub;
 		published_rel *table_info = (published_rel *) list_nth(table_infos, funcctx->call_cntr);
-		Oid			relid = table_info->relid;
-		Oid			schemaid = get_rel_namespace(relid);
-		Datum		values[NUM_PUBLICATION_TABLES_ELEM] = {0};
-		bool		nulls[NUM_PUBLICATION_TABLES_ELEM] = {0};
-
-		/*
-		 * Form tuple with appropriate data.
-		 */
 
-		pub = GetPublication(table_info->pubid);
+		rettuple = construct_published_rel_tuple(table_info, funcctx->tuple_desc);
 
-		values[0] = ObjectIdGetDatum(pub->oid);
-		values[1] = ObjectIdGetDatum(relid);
+		SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(rettuple));
+	}
 
-		/*
-		 * We don't consider row filters or column lists for FOR ALL TABLES or
-		 * FOR TABLES IN SCHEMA publications.
-		 */
-		if (!pub->alltables &&
-			!SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
-								   ObjectIdGetDatum(schemaid),
-								   ObjectIdGetDatum(pub->oid)))
-			pubtuple = SearchSysCacheCopy2(PUBLICATIONRELMAP,
-										   ObjectIdGetDatum(relid),
-										   ObjectIdGetDatum(pub->oid));
-
-		if (HeapTupleIsValid(pubtuple))
-		{
-			/* Lookup the column list attribute. */
-			values[2] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
-										Anum_pg_publication_rel_prattrs,
-										&(nulls[2]));
-
-			/* Null indicates no filter. */
-			values[3] = SysCacheGetAttr(PUBLICATIONRELMAP, pubtuple,
-										Anum_pg_publication_rel_prqual,
-										&(nulls[3]));
-		}
-		else
-		{
-			nulls[2] = true;
-			nulls[3] = true;
-		}
+	SRF_RETURN_DONE(funcctx);
+}
 
-		/* 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;
+/*
+ * Similar to pg_get_publication_tables(), but retrieves publication
+ * information only for the specified table.
+ */
+Datum
+pg_get_publication_table_info(PG_FUNCTION_ARGS)
+{
+	FuncCallContext *funcctx;
+	published_rel *table_info = NULL;
 
-			attnums = palloc_array(int16, desc->natts);
+	if (SRF_IS_FIRSTCALL())
+	{
+		MemoryContext oldcontext;
+		Oid			relid;
+		Name		pubname;
+		Relation	rel;
+		Publication *pub;
+		published_rel *pubrel = NULL;
 
-			for (i = 0; i < desc->natts; i++)
-			{
-				Form_pg_attribute att = TupleDescAttr(desc, i);
+		/* create a function context for cross-call persistence */
+		funcctx = SRF_FIRSTCALL_INIT();
 
-				if (att->attisdropped)
-					continue;
+		/* switch to memory context appropriate for multiple function calls */
+		oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
 
-				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;
-			}
+		relid = PG_GETARG_OID(0);
+		pubname = PG_GETARG_NAME(1);
 
-			if (nattnums > 0)
-			{
-				values[2] = PointerGetDatum(buildint2vector(attnums, nattnums));
-				nulls[2] = false;
-			}
+		rel = table_open(relid, AccessShareLock);
+		pub = GetPublicationByName(NameStr(*pubname), false);
 
-			table_close(rel, AccessShareLock);
+		if (is_table_publishable_in_publication(relid, pub))
+		{
+			pubrel = palloc_object(published_rel);
+			pubrel->relid = relid;
+			pubrel->pubid = pub->oid;
 		}
 
-		rettuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
+		table_close(rel, AccessShareLock);
+
+		/* Construct a tuple descriptor for the result rows. */
+		funcctx->tuple_desc = create_published_rel_tuple_desc();
+		funcctx->user_fctx = pubrel;
+
+		MemoryContextSwitchTo(oldcontext);
+	}
+
+	/* stuff done on every call of the function */
+	funcctx = SRF_PERCALL_SETUP();
+	table_info = (published_rel *) funcctx->user_fctx;
+
+	/* The function returns zero or one tuple */
+	if (table_info && funcctx->call_cntr == 0)
+	{
+		HeapTuple	rettuple;
+
+		rettuple = construct_published_rel_tuple(table_info, funcctx->tuple_desc);
 
 		SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(rettuple));
 	}
diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c
index f49a4852ecb..ce7afd68533 100644
--- a/src/backend/replication/logical/tablesync.c
+++ b/src/backend/replication/logical/tablesync.c
@@ -798,17 +798,34 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
 		 * publications).
 		 */
 		resetStringInfo(&cmd);
-		appendStringInfo(&cmd,
-						 "SELECT DISTINCT"
-						 "  (CASE WHEN (array_length(gpt.attrs, 1) = c.relnatts)"
-						 "   THEN NULL ELSE gpt.attrs END)"
-						 "  FROM pg_publication p,"
-						 "  LATERAL pg_get_publication_tables(p.pubname) gpt,"
-						 "  pg_class c"
-						 " WHERE gpt.relid = %u AND c.oid = gpt.relid"
-						 "   AND p.pubname IN ( %s )",
-						 lrel->remoteid,
-						 pub_names->data);
+
+		if (server_version >= 190000)
+		{
+			/* pg_get_publication_table_info() is available since vesion 19 */
+			appendStringInfo(&cmd,
+							 "SELECT DISTINCT"
+							 "  (CASE WHEN (array_length(gpt.attrs, 1) = c.relnatts)"
+							 "   THEN NULL ELSE gpt.attrs END)"
+							 "  FROM pg_publication p,"
+							 "  LATERAL pg_get_publication_table_info(%u, p.pubname) gpt,"
+							 "  pg_class c"
+							 " WHERE c.oid = gpt.relid"
+							 "   AND p.pubname IN ( %s )",
+							 lrel->remoteid,
+							 pub_names->data);
+		}
+		else
+			appendStringInfo(&cmd,
+							 "SELECT DISTINCT"
+							 "  (CASE WHEN (array_length(gpt.attrs, 1) = c.relnatts)"
+							 "   THEN NULL ELSE gpt.attrs END)"
+							 "  FROM pg_publication p,"
+							 "  LATERAL pg_get_publication_tables(p.pubname) gpt,"
+							 "  pg_class c"
+							 " WHERE gpt.relid = %u AND c.oid = gpt.relid"
+							 "   AND p.pubname IN ( %s )",
+							 lrel->remoteid,
+							 pub_names->data);
 
 		pubres = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data,
 							 lengthof(attrsRow), attrsRow);
@@ -982,14 +999,27 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
 
 		/* Check for row filters. */
 		resetStringInfo(&cmd);
-		appendStringInfo(&cmd,
-						 "SELECT DISTINCT pg_get_expr(gpt.qual, gpt.relid)"
-						 "  FROM pg_publication p,"
-						 "  LATERAL pg_get_publication_tables(p.pubname) gpt"
-						 " WHERE gpt.relid = %u"
-						 "   AND p.pubname IN ( %s )",
-						 lrel->remoteid,
-						 pub_names->data);
+
+		if (server_version >= 190000)
+		{
+			/* pg_get_publication_table_info() is available since version 19 */
+			appendStringInfo(&cmd,
+							 "SELECT DISTINCT pg_get_expr(gpt.qual, gpt.relid)"
+							 "  FROM pg_publication p,"
+							 "  LATERAL pg_get_publication_table_info(%u, p.pubname) gpt"
+							 " WHERE  p.pubname IN ( %s )",
+							 lrel->remoteid,
+							 pub_names->data);
+		}
+		else
+			appendStringInfo(&cmd,
+							 "SELECT DISTINCT pg_get_expr(gpt.qual, gpt.relid)"
+							 "  FROM pg_publication p,"
+							 "  LATERAL pg_get_publication_tables(p.pubname) gpt"
+							 " WHERE gpt.relid = %u"
+							 "   AND p.pubname IN ( %s )",
+							 lrel->remoteid,
+							 pub_names->data);
 
 		res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data, 1, qualRow);
 
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 361e2cfffeb..b357a67ba7d 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12451,6 +12451,15 @@
   proargmodes => '{v,o,o,o,o}',
   proargnames => '{pubname,pubid,relid,attrs,qual}',
   prosrc => 'pg_get_publication_tables' },
+{ oid => '8060',
+  descr => 'get information of the table that is part of the specified publication',
+  proname => 'pg_get_publication_table_info', prorows => '1',
+  proretset => 't', provolatile => 's',
+  prorettype => 'record', proargtypes => 'oid name',
+  proallargtypes => '{oid,name,oid,oid,int2vector,pg_node_tree}',
+  proargmodes => '{i,i,o,o,o,o}',
+  proargnames => '{relid,pubname,pubid,relid,attrs,qual}',
+  prosrc => 'pg_get_publication_table_info' },
 { oid => '8052', descr => 'get OIDs of sequences in a publication',
   proname => 'pg_get_publication_sequences', prorows => '1000', proretset => 't',
   provolatile => 's', prorettype => 'oid', proargtypes => 'text',
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 681d2564ed5..e9914c147fa 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -2182,6 +2182,135 @@ DROP TABLE testpub_merge_pk;
 RESET SESSION AUTHORIZATION;
 DROP ROLE regress_publication_user, regress_publication_user2;
 DROP ROLE regress_publication_user_dummy;
+-- Test pg_get_publication_table_info() function
+CREATE SCHEMA gpt_test_sch;
+CREATE TABLE gpt_test_sch.tbl_sch (id int);
+CREATE TABLE tbl_normal (id int);
+CREATE TABLE tbl_parent (id1 int, id2 int, id3 int) PARTITION BY RANGE (id1);
+CREATE TABLE tbl_part1 PARTITION OF tbl_parent FOR VALUES FROM (1) TO (10);
+CREATE PUBLICATION pub_all FOR ALL TABLES WITH (publish_via_partition_root = true);
+CREATE PUBLICATION pub_all_novia_root FOR ALL TABLES WITH (publish_via_partition_root = false);
+CREATE PUBLICATION pub_all_except FOR ALL TABLES EXCEPT TABLE (tbl_parent, gpt_test_sch.tbl_sch);
+CREATE PUBLICATION pub_schema FOR TABLES IN SCHEMA gpt_test_sch;
+CREATE PUBLICATION pub_normal FOR TABLE tbl_normal WHERE (id < 10);
+CREATE PUBLICATION pub_part_leaf FOR TABLE tbl_part1 WITH (publish_via_partition_root = false);
+CREATE PUBLICATION pub_part_parent FOR TABLE tbl_parent (id1, id2) WHERE (id1 = 10) WITH (publish_via_partition_root = true);
+CREATE PUBLICATION pub_part_parent_novia_root FOR TABLE tbl_parent WITH (publish_via_partition_root = false);
+CREATE FUNCTION test_gpt(pubname text, relname text)
+RETURNS TABLE (
+  pubname text,
+  relname name,
+  attrs text,
+  qual text
+)
+BEGIN ATOMIC
+  SELECT p.pubname, c.relname, gpt.attrs::text, pg_get_expr(gpt.qual, gpt.relid)
+    FROM pg_get_publication_table_info(relname::regclass::oid, pubname) gpt
+    JOIN pg_publication p ON p.oid = gpt.pubid
+    JOIN pg_class c ON c.oid = gpt.relid
+  ORDER BY p.pubname, c.relname;
+END;
+SELECT * FROM test_gpt('pub_normal', 'tbl_normal');
+  pubname   |  relname   | attrs |   qual    
+------------+------------+-------+-----------
+ pub_normal | tbl_normal | 1     | (id < 10)
+(1 row)
+
+SELECT * FROM test_gpt('pub_schema', 'tbl_normal'); -- no result
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_part_parent', 'tbl_parent');
+     pubname     |  relname   | attrs |    qual    
+-----------------+------------+-------+------------
+ pub_part_parent | tbl_parent | 1 2   | (id1 = 10)
+(1 row)
+
+SELECT * FROM test_gpt('pub_part_parent', 'tbl_part1'); -- no result
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_part_parent_novia_root', 'tbl_parent'); -- no result
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_part_parent_novia_root', 'tbl_part1');
+          pubname           |  relname  | attrs | qual 
+----------------------------+-----------+-------+------
+ pub_part_parent_novia_root | tbl_part1 | 1 2 3 | 
+(1 row)
+
+SELECT * FROM test_gpt('pub_part_leaf', 'tbl_parent'); -- no result
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_part_leaf', 'tbl_part1');
+    pubname    |  relname  | attrs | qual 
+---------------+-----------+-------+------
+ pub_part_leaf | tbl_part1 | 1 2 3 | 
+(1 row)
+
+SELECT * FROM test_gpt('pub_all', 'tbl_parent');
+ pubname |  relname   | attrs | qual 
+---------+------------+-------+------
+ pub_all | tbl_parent | 1 2 3 | 
+(1 row)
+
+SELECT * FROM test_gpt('pub_all', 'tbl_part1'); -- no result
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_all_except', 'tbl_normal');
+    pubname     |  relname   | attrs | qual 
+----------------+------------+-------+------
+ pub_all_except | tbl_normal | 1     | 
+(1 row)
+
+SELECT * FROM test_gpt('pub_all_except', 'gpt_test_sch.tbl_sch'); -- no result (excluded)
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_all_except', 'tbl_parent'); -- no result (excluded)
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_all_except', 'tbl_part1'); -- no result (excluded)
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_all_novia_root', 'tbl_parent'); -- no result
+ pubname | relname | attrs | qual 
+---------+---------+-------+------
+(0 rows)
+
+SELECT * FROM test_gpt('pub_all_novia_root', 'tbl_part1');
+      pubname       |  relname  | attrs | qual 
+--------------------+-----------+-------+------
+ pub_all_novia_root | tbl_part1 | 1 2 3 | 
+(1 row)
+
+-- Clean up
+DROP FUNCTION test_gpt(text[], relname);
+ERROR:  type "relname" does not exist
+DROP PUBLICATION pub_all;
+DROP PUBLICATION pub_all_novia_root;
+DROP PUBLICATION pub_all_except;
+DROP PUBLICATION pub_schema;
+DROP PUBLICATION pub_normal;
+DROP PUBLICATION pub_part_leaf;
+DROP PUBLICATION pub_part_parent;
+DROP PUBLICATION pub_part_parent_novia_root;
+DROP TABLE tbl_normal, tbl_parent, tbl_part1;
+DROP SCHEMA gpt_test_sch CASCADE;
+NOTICE:  drop cascades to table gpt_test_sch.tbl_sch
 -- stage objects for pg_dump tests
 CREATE SCHEMA pubme CREATE TABLE t0 (c int, d int) CREATE TABLE t1 (c int);
 CREATE SCHEMA pubme2 CREATE TABLE t0 (c int, d int);
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index 405579dad52..75f1bc2f2fc 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -1378,6 +1378,73 @@ RESET SESSION AUTHORIZATION;
 DROP ROLE regress_publication_user, regress_publication_user2;
 DROP ROLE regress_publication_user_dummy;
 
+-- Test pg_get_publication_table_info() function
+CREATE SCHEMA gpt_test_sch;
+CREATE TABLE gpt_test_sch.tbl_sch (id int);
+CREATE TABLE tbl_normal (id int);
+CREATE TABLE tbl_parent (id1 int, id2 int, id3 int) PARTITION BY RANGE (id1);
+CREATE TABLE tbl_part1 PARTITION OF tbl_parent FOR VALUES FROM (1) TO (10);
+
+CREATE PUBLICATION pub_all FOR ALL TABLES WITH (publish_via_partition_root = true);
+CREATE PUBLICATION pub_all_novia_root FOR ALL TABLES WITH (publish_via_partition_root = false);
+CREATE PUBLICATION pub_all_except FOR ALL TABLES EXCEPT TABLE (tbl_parent, gpt_test_sch.tbl_sch);
+CREATE PUBLICATION pub_schema FOR TABLES IN SCHEMA gpt_test_sch;
+CREATE PUBLICATION pub_normal FOR TABLE tbl_normal WHERE (id < 10);
+CREATE PUBLICATION pub_part_leaf FOR TABLE tbl_part1 WITH (publish_via_partition_root = false);
+CREATE PUBLICATION pub_part_parent FOR TABLE tbl_parent (id1, id2) WHERE (id1 = 10) WITH (publish_via_partition_root = true);
+CREATE PUBLICATION pub_part_parent_novia_root FOR TABLE tbl_parent WITH (publish_via_partition_root = false);
+
+CREATE FUNCTION test_gpt(pubname text, relname text)
+RETURNS TABLE (
+  pubname text,
+  relname name,
+  attrs text,
+  qual text
+)
+BEGIN ATOMIC
+  SELECT p.pubname, c.relname, gpt.attrs::text, pg_get_expr(gpt.qual, gpt.relid)
+    FROM pg_get_publication_table_info(relname::regclass::oid, pubname) gpt
+    JOIN pg_publication p ON p.oid = gpt.pubid
+    JOIN pg_class c ON c.oid = gpt.relid
+  ORDER BY p.pubname, c.relname;
+END;
+
+SELECT * FROM test_gpt('pub_normal', 'tbl_normal');
+SELECT * FROM test_gpt('pub_schema', 'tbl_normal'); -- no result
+
+SELECT * FROM test_gpt('pub_part_parent', 'tbl_parent');
+SELECT * FROM test_gpt('pub_part_parent', 'tbl_part1'); -- no result
+
+SELECT * FROM test_gpt('pub_part_parent_novia_root', 'tbl_parent'); -- no result
+SELECT * FROM test_gpt('pub_part_parent_novia_root', 'tbl_part1');
+
+SELECT * FROM test_gpt('pub_part_leaf', 'tbl_parent'); -- no result
+SELECT * FROM test_gpt('pub_part_leaf', 'tbl_part1');
+
+SELECT * FROM test_gpt('pub_all', 'tbl_parent');
+SELECT * FROM test_gpt('pub_all', 'tbl_part1'); -- no result
+
+SELECT * FROM test_gpt('pub_all_except', 'tbl_normal');
+SELECT * FROM test_gpt('pub_all_except', 'gpt_test_sch.tbl_sch'); -- no result (excluded)
+SELECT * FROM test_gpt('pub_all_except', 'tbl_parent'); -- no result (excluded)
+SELECT * FROM test_gpt('pub_all_except', 'tbl_part1'); -- no result (excluded)
+
+SELECT * FROM test_gpt('pub_all_novia_root', 'tbl_parent'); -- no result
+SELECT * FROM test_gpt('pub_all_novia_root', 'tbl_part1');
+
+-- Clean up
+DROP FUNCTION test_gpt(text[], relname);
+DROP PUBLICATION pub_all;
+DROP PUBLICATION pub_all_novia_root;
+DROP PUBLICATION pub_all_except;
+DROP PUBLICATION pub_schema;
+DROP PUBLICATION pub_normal;
+DROP PUBLICATION pub_part_leaf;
+DROP PUBLICATION pub_part_parent;
+DROP PUBLICATION pub_part_parent_novia_root;
+DROP TABLE tbl_normal, tbl_parent, tbl_part1;
+DROP SCHEMA gpt_test_sch CASCADE;
+
 -- stage objects for pg_dump tests
 CREATE SCHEMA pubme CREATE TABLE t0 (c int, d int) CREATE TABLE t1 (c int);
 CREATE SCHEMA pubme2 CREATE TABLE t0 (c int, d int);
-- 
2.53.0

