From b42b4a34e9070b37a02e2c35fd754893af04e14d Mon Sep 17 00:00:00 2001
From: Nisha Moond <nisha.moond412@gmail.com>
Date: Wed, 15 Jul 2026 21:18:51 +0530
Subject: [PATCH v21 2/5] Restrict conflicting EXCEPT lists in multi-schema
 publications

Add ProcessSchemaExceptTables() to qualify each mention's EXCEPT
entries, build a sorted signature per schema OID, and compare it
against any prior mention of the same schema in the same statement,
erroring on a mismatch. It also checks there are no clashes of the
same schema with or without EXCEPT.
---
 src/backend/commands/publicationcmds.c    | 116 +++++++++++++++-------
 src/backend/parser/gram.y                 |  37 +------
 src/test/regress/expected/publication.out |  38 +++++++
 src/test/regress/sql/publication.sql      |  31 ++++++
 4 files changed, 154 insertions(+), 68 deletions(-)

diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index edc8a20ae81..ba5f6bf57b9 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -73,6 +73,10 @@ static void PublicationAddSchemas(Oid pubid, List *schemas, bool if_not_exists,
 static void PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok);
 static char defGetGeneratedColsOption(DefElem *def);
 static void CheckExceptNotInTableList(List *except_rels, List *explicitrelids);
+static void ProcessSchemaExceptTables(Oid schemaid, List *except_tables,
+									  ParseState *pstate, List **schemas,
+									  List **schemas_with_except,
+									  List **except_pubtables);
 
 
 static void
@@ -177,6 +181,73 @@ parse_publication_options(ParseState *pstate,
 	}
 }
 
+/*
+ * Qualify unqualified EXCEPT table names with the given schema, reject
+ * entries explicitly qualified with a different schema, and append the (now
+ * qualified) tables to *except_pubtables.
+ *
+ * Also rejects a repeated mention of the same schema in the same TABLES IN
+ * SCHEMA list if it, or an earlier mention of the same schema, specified a
+ * non-empty EXCEPT list, without checking whether the EXCEPT lists across
+ * all the mentions actually name the same tables. This mirrors
+ * OpenTableList()'s handling of repeated tables with column lists (e.g.
+ * "FOR TABLE t1(a), t1(a)" is rejected even though the lists match):
+ * repeated mentions of the same schema wouldn't have a well-defined single
+ * EXCEPT list to enforce, so they're rejected as conflicting or redundant
+ * rather than silently unioned or compared for equivalence.  A schema may
+ * still be mentioned multiple times as long as none of the mentions
+ * specify EXCEPT.
+ * *schemas_with_except accumulates the OIDs of schemas already seen with a
+ * non-empty EXCEPT list in this statement.
+ */
+static void
+ProcessSchemaExceptTables(Oid schemaid, List *except_tables,
+						  ParseState *pstate, List **schemas,
+						  List **schemas_with_except, List **except_pubtables)
+{
+	char	   *schema_name = get_namespace_name(schemaid);
+
+	/*
+	 * A repeat is only a problem if this mention has EXCEPT, or an earlier
+	 * mention of the same schema did.
+	 */
+	if (list_member_oid(*schemas, schemaid))
+	{
+		if (except_tables != NIL ||
+			list_member_oid(*schemas_with_except, schemaid))
+			ereport(ERROR,
+					errcode(ERRCODE_DUPLICATE_OBJECT),
+					errmsg("conflicting or redundant EXCEPT lists for schema \"%s\"",
+						   schema_name));
+	}
+
+	if (except_tables != NIL)
+		*schemas_with_except = lappend_oid(*schemas_with_except, schemaid);
+
+	/* Filter out duplicates if the user specifies "sch1, sch1" */
+	*schemas = list_append_unique_oid(*schemas, schemaid);
+
+	if (except_tables == NIL)
+		return;
+
+	foreach_ptr(PublicationObjSpec, eobj, except_tables)
+	{
+		RangeVar   *relation = eobj->pubtable->relation;
+
+		if (relation->schemaname == NULL)
+			relation->schemaname = schema_name;
+		else if (strcmp(relation->schemaname, schema_name) != 0)
+			ereport(ERROR,
+					errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
+					errmsg("table \"%s\" in EXCEPT clause does not belong to schema \"%s\"",
+						   quote_qualified_identifier(relation->schemaname, relation->relname),
+						   schema_name),
+					parser_errposition(pstate, eobj->location));
+
+		*except_pubtables = lappend(*except_pubtables, eobj->pubtable);
+	}
+}
+
 /*
  * Convert the PublicationObjSpecType list into schema oid list and
  * PublicationTable list.
@@ -187,6 +258,7 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
 {
 	ListCell   *cell;
 	PublicationObjSpec *pubobj;
+	List	   *schemas_with_except = NIL;
 
 	if (!pubobjspec_list)
 		return;
@@ -211,8 +283,10 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
 			case PUBLICATIONOBJ_TABLES_IN_SCHEMA:
 				schemaid = get_namespace_oid(pubobj->name, false);
 
-				/* Filter out duplicates if user specifies "sch1, sch1" */
-				*schemas = list_append_unique_oid(*schemas, schemaid);
+				ProcessSchemaExceptTables(schemaid, pubobj->except_tables,
+										  pstate, schemas,
+										  &schemas_with_except,
+										  except_pubtables);
 				break;
 			case PUBLICATIONOBJ_TABLES_IN_CUR_SCHEMA:
 				search_path = fetch_search_path(false);
@@ -224,40 +298,10 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
 				schemaid = linitial_oid(search_path);
 				list_free(search_path);
 
-				/* Filter out duplicates if user specifies "sch1, sch1" */
-				*schemas = list_append_unique_oid(*schemas, schemaid);
-
-				/*
-				 * Qualify unqualified EXCEPT table names with the resolved
-				 * current schema and reject any explicitly cross-schema
-				 * entries.  This mirrors the parse-time handling done for
-				 * TABLES_IN_SCHEMA in preprocess_pubobj_list(), deferred here
-				 * because CURRENT_SCHEMA is not known until execution time.
-				 */
-				if (pubobj->except_tables != NIL)
-				{
-					char	   *cur_schema_name = get_namespace_name(schemaid);
-
-					foreach_ptr(PublicationObjSpec, eobj, pubobj->except_tables)
-					{
-						const char *eobj_schemaname =
-							eobj->pubtable->relation->schemaname;
-						const char *eobj_relname =
-							eobj->pubtable->relation->relname;
-
-						if (eobj_schemaname == NULL)
-							eobj->pubtable->relation->schemaname = cur_schema_name;
-						else if (strcmp(eobj_schemaname, cur_schema_name) != 0)
-							ereport(ERROR,
-									errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
-									errmsg("table \"%s\" in EXCEPT clause does not belong to schema \"%s\"",
-										   quote_qualified_identifier(eobj_schemaname, eobj_relname),
-										   cur_schema_name));
-
-						*except_pubtables = lappend(*except_pubtables,
-													eobj->pubtable);
-					}
-				}
+				ProcessSchemaExceptTables(schemaid, pubobj->except_tables,
+										  pstate, schemas,
+										  &schemas_with_except,
+										  except_pubtables);
 				break;
 			default:
 				/* shouldn't happen */
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 2a8f999d586..f38c9f332ba 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -20878,8 +20878,11 @@ preprocess_pub_all_objtype_list(List *all_objects_list, List **pubobjects,
 /*
  * Process pubobjspec_list to check for errors in any of the objects and
  * convert PUBLICATIONOBJ_CONTINUATION into appropriate PublicationObjSpecType.
- * Also flattens except_tables from TABLES IN SCHEMA nodes into the list so
- * that ObjectsInPublicationToOids() sees them as top-level EXCEPT_TABLE entries.
+ *
+ * The except_tables attached to TABLES IN SCHEMA nodes are left in place here;
+ * ObjectsInPublicationToOids() qualifies their names, validates schema
+ * membership, and merges the qualified tables into its except_pubtables
+ * output list once the schema OID is known.
  */
 static void
 preprocess_pubobj_list(List *pubobjspec_list, core_yyscan_t yyscanner)
@@ -20963,36 +20966,6 @@ preprocess_pubobj_list(List *pubobjspec_list, core_yyscan_t yyscanner)
 						errcode(ERRCODE_SYNTAX_ERROR),
 						errmsg("invalid schema name"),
 						parser_errposition(pubobj->location));
-
-			/*
-			 * For TABLES_IN_SCHEMA, qualify unqualified EXCEPT table names
-			 * with the parent schema and reject cross-schema entries at parse
-			 * time, then flatten into the top-level list.
-			 *
-			 * For TABLES_IN_CUR_SCHEMA the schema name is not yet known, so
-			 * skip both steps here; ObjectsInPublicationToOids() will
-			 * qualify names and validate schema membership at execution time.
-			 */
-			if (pubobj->pubobjtype == PUBLICATIONOBJ_TABLES_IN_SCHEMA)
-			{
-				foreach_ptr(PublicationObjSpec, eobj, pubobj->except_tables)
-				{
-					const char *eobj_schemaname = eobj->pubtable->relation->schemaname;
-					const char *eobj_relname = eobj->pubtable->relation->relname;
-
-					if (eobj_schemaname == NULL)
-						eobj->pubtable->relation->schemaname = pubobj->name;
-					else if (strcmp(eobj_schemaname, pubobj->name) != 0)
-						ereport(ERROR,
-								errcode(ERRCODE_INVALID_OBJECT_DEFINITION),
-								errmsg("table \"%s\" in EXCEPT clause does not belong to schema \"%s\"",
-									   quote_qualified_identifier(eobj_schemaname, eobj_relname),
-									   pubobj->name),
-								parser_errposition(eobj->location));
-				}
-				pubobjspec_list = list_concat(pubobjspec_list, pubobj->except_tables);
-				pubobj->except_tables = NIL;
-			}
 		}
 
 		prevobjtype = pubobj->pubobjtype;
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index d4c213d4c84..eb115d4e433 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -552,6 +552,17 @@ Except tables:
     "pub_test.testpub_tbl_s1"
     "public.testpub_tbl1"
 
+-- fail: same schema repeated with same EXCEPT lists
+CREATE PUBLICATION testpub_schema_except_conflict
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+ERROR:  conflicting or redundant EXCEPT lists for schema "pub_test"
+-- fail: same schema repeated with conflicting or no EXCEPT lists
+CREATE PUBLICATION testpub_schema_except_conflict2
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2),
+                  pub_test;
+ERROR:  conflicting or redundant EXCEPT lists for schema "pub_test"
 -- ALTER TABLE ... SET SCHEMA on a table excluded by a schema publication:
 -- the schema-scoped exclusion is no longer meaningful once the table moves
 -- out of its schema, so the exclusion is auto-removed.
@@ -2129,6 +2140,8 @@ SET search_path = pub_test1;
 -- qualified name from wrong schema -> error
 CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE pub_test2.tbl1);
 ERROR:  table "pub_test2.tbl1" in EXCEPT clause does not belong to schema "pub_test1"
+LINE 1: ...FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE pub_test2....
+                                                             ^
 -- unqualified name implicitly qualified with current schema (pub_test1.tbl)
 SET client_min_messages = 'ERROR';
 CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl);
@@ -2144,6 +2157,31 @@ Except tables:
     "pub_test1.tbl"
 
 DROP PUBLICATION testpub_cursch_except;
+-- succeeds: CURRENT_SCHEMA and pub_test1 (same schema)
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub_cursch_named_same
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA, pub_test1;
+RESET client_min_messages;
+\dRp+ testpub_cursch_named_same
+                                                    Publication testpub_cursch_named_same
+          Owner           | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root | Description 
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------+-------------
+ regress_publication_user | f          | f             | t       | t       | t       | t         | none              | f        | 
+Tables from schemas:
+    "pub_test1"
+
+DROP PUBLICATION testpub_cursch_named_same;
+-- fail: CURRENT_SCHEMA and pub_test1 (same schema) have
+-- conflicting EXCEPT lists
+CREATE PUBLICATION testpub_cursch_named_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  pub_test1 EXCEPT (TABLE tbl1);
+ERROR:  conflicting or redundant EXCEPT lists for schema "pub_test1"
+-- fail: two CURRENT_SCHEMA mentions with conflicting EXCEPT lists
+CREATE PUBLICATION testpub_cursch_cursch_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  CURRENT_SCHEMA EXCEPT (TABLE tbl1);
+ERROR:  conflicting or redundant EXCEPT lists for schema "pub_test1"
 RESET search_path;
 -- cleanup pub_test1 schema for invalidation tests
 ALTER PUBLICATION testpub2_forschema DROP TABLES IN SCHEMA pub_test1;
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index 4a86d699e84..4a79055c4f3 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -262,6 +262,17 @@ CREATE PUBLICATION testpub_schema_except_multi
                   public EXCEPT (TABLE testpub_tbl1);
 \dRp+ testpub_schema_except_multi
 
+-- fail: same schema repeated with same EXCEPT lists
+CREATE PUBLICATION testpub_schema_except_conflict
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+
+-- fail: same schema repeated with conflicting or no EXCEPT lists
+CREATE PUBLICATION testpub_schema_except_conflict2
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                  pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2),
+                  pub_test;
+
 -- ALTER TABLE ... SET SCHEMA on a table excluded by a schema publication:
 -- the schema-scoped exclusion is no longer meaningful once the table moves
 -- out of its schema, so the exclusion is auto-removed.
@@ -1304,6 +1315,26 @@ CREATE PUBLICATION testpub_cursch_except FOR TABLES IN SCHEMA CURRENT_SCHEMA EXC
 RESET client_min_messages;
 \dRp+ testpub_cursch_except
 DROP PUBLICATION testpub_cursch_except;
+
+-- succeeds: CURRENT_SCHEMA and pub_test1 (same schema)
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub_cursch_named_same
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA, pub_test1;
+RESET client_min_messages;
+\dRp+ testpub_cursch_named_same
+DROP PUBLICATION testpub_cursch_named_same;
+
+-- fail: CURRENT_SCHEMA and pub_test1 (same schema) have
+-- conflicting EXCEPT lists
+CREATE PUBLICATION testpub_cursch_named_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  pub_test1 EXCEPT (TABLE tbl1);
+
+-- fail: two CURRENT_SCHEMA mentions with conflicting EXCEPT lists
+CREATE PUBLICATION testpub_cursch_cursch_conflict
+    FOR TABLES IN SCHEMA CURRENT_SCHEMA EXCEPT (TABLE tbl),
+                  CURRENT_SCHEMA EXCEPT (TABLE tbl1);
+
 RESET search_path;
 
 -- cleanup pub_test1 schema for invalidation tests
-- 
2.50.1 (Apple Git-155)

