From 3da16d8dfb52bf65de1498fe99e6db271cb0ec5a Mon Sep 17 00:00:00 2001
From: Vignesh C <c_vigneshwaran@apple.com>
Date: Fri, 14 Aug 2026 15:11:18 +0530
Subject: [PATCH v27 3/4] Add EXCEPT support to ALTER PUBLICATION SET TABLES IN
 SCHEMA

Extend AlterPublicationExceptTables() with the AP_SetObjects case,
which redefines the publication and replaces the entire EXCEPT list.

Syntax:
ALTER PUBLICATION pub SET TABLES IN SCHEMA s EXCEPT (TABLE t1);

This patch also cleans up EXCEPT entries when a schema is dropped
from the publication.
---
 src/backend/commands/publicationcmds.c    | 319 +++++++++++++++++-----
 src/bin/psql/tab-complete.in.c            |  17 ++
 src/test/regress/expected/publication.out | 161 ++++++++++-
 src/test/regress/sql/publication.sql      |  69 +++++
 src/test/subscription/t/037_except.pl     |  85 ++++++
 5 files changed, 575 insertions(+), 76 deletions(-)

diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index 30fccf68d1d..0f5412fc095 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -67,13 +67,14 @@ static void CloseTableList(List *rels);
 static void LockSchemaList(List *schemalist);
 static void PublicationAddTables(Oid pubid, List *rels, bool if_not_exists,
 								 AlterPublicationStmt *stmt);
-static void PublicationDropTables(Oid pubid, List *rels, bool missing_ok);
+static void PublicationDropTables(Oid pubid, List *rels, bool missing_ok,
+								  bool delete_excluded);
 static void PublicationAddSchemas(Oid pubid, List *schemas, bool if_not_exists,
 								  AlterPublicationStmt *stmt);
 static void PublicationAddExceptTables(Oid pubid, List *except_pubtables,
-									  List *explicitrelids,
-									  List *schemaidlist,
-									  AlterPublicationStmt *stmt);
+									   List *explicitrelids,
+									   List *schemaidlist,
+									   AlterPublicationStmt *stmt);
 static void PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok);
 static void AlterPublicationSchemas(AlterPublicationStmt *stmt,
 									HeapTuple tup, List *schemaidlist,
@@ -89,7 +90,12 @@ static void CheckExceptNotInTableList(List *except_rels,
 static void CheckExceptChildNotInSchemaList(Oid pubid,
 											List *cross_schema_children,
 											List *pubschemas,
-											List *except_rels);
+											List *except_rels,
+											bool existing_except_kept);
+static void CheckExceptConflicts(Oid pubid, List *except_rels,
+								 List *cross_schema_children,
+								 List *explicitrelids, List *pubschemas,
+								 bool existing_except_kept);
 static void ProcessSchemaExceptTables(Oid schemaid, List *except_tables,
 									  ParseState *pstate, List **schemas,
 									  List **schemas_with_except,
@@ -264,9 +270,9 @@ ProcessSchemaExceptTables(Oid schemaid, List *except_tables,
 					parser_errposition(pstate, eobj->location));
 
 		/*
-		 * Remember that this EXCEPT entry is associated with a schema. This is
-		 * used by OpenTableList() to skip inheritance children outside that
-		 * schema.
+		 * Remember that this EXCEPT entry is associated with a schema. This
+		 * is used by OpenTableList() to skip inheritance children outside
+		 * that schema.
 		 */
 		eobj->pubtable->except_in_schema = true;
 
@@ -938,8 +944,9 @@ CheckExceptNotInTableList(List *except_rels, List *cross_schema_children,
 		/*
 		 * An EXCEPT clause also applies to inheritance children of a parent
 		 * named without ONLY. Children in other schemas are not added to
-		 * except_rels, but are still excluded by the EXCEPT clause. Explicitly
-		 * publishing such a child therefore conflicts with the EXCEPT clause.
+		 * except_rels, but are still excluded by the EXCEPT clause.
+		 * Explicitly publishing such a child therefore conflicts with the
+		 * EXCEPT clause.
 		 */
 		if (list_member_oid(cross_schema_children, explicitrelid))
 			ereport(ERROR,
@@ -962,16 +969,18 @@ CheckExceptNotInTableList(List *except_rels, List *cross_schema_children,
  * the schemas that this statement publishes and those the publication already
  * publishes; ALTER PUBLICATION ... ADD names only the former.
  *
- * If the child is excluded anyway, there is no conflict, since both sides
- * agree. That is so when an EXCEPT clause of this statement names it, and also
- * when the publication already holds an EXCEPT entry for it.
+ * If the child ends up excluded anyway, there is no conflict, since both sides
+ * agree. That is so when an EXCEPT clause of this statement names it, and,
+ * where existing_except_kept says such entries remain in force, when the
+ * publication already holds an EXCEPT entry for it.
  *
  * Conflicts with explicitly listed tables are handled by
  * CheckExceptNotInTableList().
  */
 static void
 CheckExceptChildNotInSchemaList(Oid pubid, List *cross_schema_children,
-								List *pubschemas, List *except_rels)
+								List *pubschemas, List *except_rels,
+								bool existing_except_kept)
 {
 	foreach_oid(childrelid, cross_schema_children)
 	{
@@ -992,8 +1001,8 @@ CheckExceptChildNotInSchemaList(Oid pubid, List *cross_schema_children,
 			}
 		}
 
-		/* Or does the publication already exclude it? */
-		if (!excluded &&
+		/* Or is it already excluded, by an entry that remains in force? */
+		if (!excluded && existing_except_kept &&
 			CheckPublicationRelEntry(pubid, childrelid, &is_except) &&
 			is_except)
 			excluded = true;
@@ -1013,13 +1022,56 @@ CheckExceptChildNotInSchemaList(Oid pubid, List *cross_schema_children,
 	}
 }
 
+/*
+ * Check that the EXCEPT entries do not conflict with the relations and
+ * schemas included by the publication after the statement completes.
+ *
+ * except_rels and cross_schema_children contain the EXCEPT entries collected
+ * by OpenTableList(), while explicitrelids contains explicitly included
+ * relations.
+ *
+ * pubschemas contains the schemas included by the publication after the
+ * statement.
+ *
+ * existing_except_kept says whether the EXCEPT entries the publication already
+ * holds are still in force once the statement completes.  ADD only adds
+ * entries, so they are.  SET replaces the whole list, so an existing entry may
+ * be one this statement drops, and must not be taken as agreement that a
+ * cross-schema child is excluded.
+ */
+static void
+CheckExceptConflicts(Oid pubid, List *except_rels,
+					 List *cross_schema_children, List *explicitrelids,
+					 List *pubschemas, bool existing_except_kept)
+{
+	/*
+	 * Validate that a table is not both explicitly included and excluded by
+	 * the schema's EXCEPT clause.
+	 */
+	CheckExceptNotInTableList(except_rels, cross_schema_children,
+							  explicitrelids);
+
+	/*
+	 * Validate that an inheritance child is not both excluded by an EXCEPT
+	 * clause and included by its schema.
+	 */
+	CheckExceptChildNotInSchemaList(pubid, cross_schema_children, pubschemas,
+									except_rels, existing_except_kept);
+}
+
 /*
  * Add the EXCEPT tables of a statement's TABLES IN SCHEMA clauses to a
  * publication, after checking that the statement does not contradict itself.
  *
  * explicitrelids is the set of relations the statement publishes by name, and
- * schemaidlist the schemas it publishes in full; both are needed to detect a
- * table that would be published and excluded at the same time.
+ * pubschemas the schemas the publication publishes once the statement
+ * completes; both are needed to detect a table that would be published and
+ * excluded at the same time.  See CheckExceptConflicts() for how callers pick
+ * pubschemas.
+ *
+ * This is for CREATE PUBLICATION and ALTER PUBLICATION ... ADD, which only add
+ * EXCEPT entries.  ALTER PUBLICATION ... SET replaces them, and does its own
+ * reconciliation; see AlterPublicationSchemaExceptTables().
  *
  * if_not_exists is false: an EXCEPT entry that is already present, or that
  * collides with an ordinary member, is a conflict worth reporting rather than
@@ -1027,41 +1079,20 @@ CheckExceptChildNotInSchemaList(Oid pubid, List *cross_schema_children,
  */
 static void
 PublicationAddExceptTables(Oid pubid, List *except_pubtables,
-						   List *explicitrelids, List *schemaidlist,
+						   List *explicitrelids, List *pubschemas,
 						   AlterPublicationStmt *stmt)
 {
 	List	   *except_rels;
 	List	   *cross_schema_children = NIL;
-	List	   *pubschemas;
 
 	if (except_pubtables == NIL)
 		return;
 
 	except_rels = OpenTableList(except_pubtables, &cross_schema_children);
 
-	/*
-	 * A table is published by this publication if the statement publishes its
-	 * schema, or if the publication already published it.  ALTER PUBLICATION
-	 * ... ADD names only the schemas being added, so the catalog has to be
-	 * consulted as well; otherwise a child in a schema that an earlier command
-	 * added would not be recognised as a conflict.
-	 */
-	pubschemas = list_concat_unique_oid(list_copy(schemaidlist),
-									   GetPublicationSchemas(pubid));
-
-	/*
-	 * Validate that a table is not both explicitly included and excluded by
-	 * the schema's EXCEPT clause.
-	 */
-	CheckExceptNotInTableList(except_rels, cross_schema_children,
-							  explicitrelids);
-
-	/*
-	 * Validate that an inheritance child is not both excluded by an EXCEPT
-	 * clause and included by its schema.
-	 */
-	CheckExceptChildNotInSchemaList(pubid, cross_schema_children, pubschemas,
-									except_rels);
+	CheckExceptConflicts(pubid, except_rels, cross_schema_children,
+						 explicitrelids, pubschemas,
+						 true /* existing_except_kept */ );
 
 	PublicationAddTables(pubid, except_rels, false, stmt);
 	CloseTableList(except_rels);
@@ -1305,6 +1336,11 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
 			LockSchemaList(schemaidlist);
 			PublicationAddSchemas(puboid, schemaidlist, true, NULL);
 
+			/*
+			 * The schemas this statement names are the publication's whole
+			 * set; the pg_publication_namespace rows inserted just above are
+			 * not visible to a catalog lookup within the same command.
+			 */
 			PublicationAddExceptTables(puboid, except_pubtables,
 									   explicitrelids, schemaidlist, NULL);
 		}
@@ -1587,7 +1623,7 @@ AlterPublicationTables(AlterPublicationStmt *stmt, HeapTuple tup,
 		PublicationAddTables(pubid, rels, false, stmt);
 	}
 	else if (stmt->action == AP_DropObjects)
-		PublicationDropTables(pubid, rels, false);
+		PublicationDropTables(pubid, rels, false, false);
 	else						/* AP_SetObjects */
 	{
 		List	   *oldrelids = NIL;
@@ -1735,7 +1771,7 @@ AlterPublicationTables(AlterPublicationStmt *stmt, HeapTuple tup,
 		}
 
 		/* And drop them. */
-		PublicationDropTables(pubid, delrels, true);
+		PublicationDropTables(pubid, delrels, true, true);
 
 		/*
 		 * Don't bother calculating the difference for adding, we'll catch and
@@ -1837,8 +1873,8 @@ AlterPublicationSchemas(AlterPublicationStmt *stmt,
 
 	/*
 	 * Increment the command counter so that is_schema_publication() in
-	 * GetExcludedPublicationTables() can see the just-inserted schema
-	 * rows when AlterPublicationSchemaExceptTables runs next.
+	 * GetExcludedPublicationTables() can see the just-inserted schema rows
+	 * when AlterPublicationSchemaExceptTables runs next.
 	 */
 	if (stmt->action == AP_AddObjects || stmt->action == AP_SetObjects)
 		CommandCounterIncrement();
@@ -1856,16 +1892,18 @@ AlterPublicationSchemas(AlterPublicationStmt *stmt,
  */
 static void
 AlterPublicationSchemaExceptTables(AlterPublicationStmt *stmt,
-							 HeapTuple tup, List *except_pubtables,
-							 List *schemaidlist)
+								   HeapTuple tup, List *except_pubtables,
+								   List *schemaidlist)
 {
 	Form_pg_publication pubform = (Form_pg_publication) GETSTRUCT(tup);
 	Oid			pubid = pubform->oid;
 
 	/*
-	 * Nothing to do if no EXCEPT entries.
+	 * Nothing to do if there are no EXCEPT entries, unless handling the SET
+	 * command, because if the user has removed all exceptions we need to drop
+	 * any existing ones.
 	 */
-	if (!except_pubtables)
+	if (!except_pubtables && stmt->action != AP_SetObjects)
 		return;
 
 	/*
@@ -1886,16 +1924,6 @@ AlterPublicationSchemaExceptTables(AlterPublicationStmt *stmt,
 				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				 errmsg("EXCEPT clause is not supported with DROP in ALTER PUBLICATION")));
 
-	/*
-	 * XXX EXCEPT with SET is not currently implemented.  Workaround: DROP and
-	 * re-ADD the schema with the desired EXCEPT list.
-	 */
-	if (stmt->action == AP_SetObjects)
-		ereport(ERROR,
-				(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
-				 errmsg("EXCEPT clause is not supported with SET in ALTER PUBLICATION"),
-				 errhint("Drop and re-add the schema with the desired EXCEPT list.")));
-
 	if (stmt->action == AP_AddObjects)
 	{
 		List	   *explicitrelids;
@@ -1903,8 +1931,84 @@ AlterPublicationSchemaExceptTables(AlterPublicationStmt *stmt,
 		explicitrelids = GetIncludedPublicationRelations(pubid,
 														 PUBLICATION_PART_ROOT);
 
+		/*
+		 * schemaidlist holds only the schemas being added, so read the whole
+		 * set from the catalog: AlterPublicationSchemas() has applied the
+		 * additions and incremented the command counter before calling us.
+		 */
 		PublicationAddExceptTables(pubid, except_pubtables, explicitrelids,
-								   schemaidlist, stmt);
+								   GetPublicationSchemas(pubid), stmt);
+	}
+	else
+	{
+		List	   *oldexceptrelids = NIL;
+		List	   *newexceptrelids = NIL;
+		List	   *delrelids = NIL;
+		List	   *except_rels;
+		List	   *explicitrelids;
+		List	   *cross_schema_children = NIL;
+
+		except_rels = OpenTableList(except_pubtables, &cross_schema_children);
+
+		/* Collect OIDs of the desired new EXCEPT list. */
+		foreach_ptr(PublicationRelInfo, pri, except_rels)
+			newexceptrelids = lappend_oid(newexceptrelids,
+										  RelationGetRelid(pri->relation));
+
+		explicitrelids = GetIncludedPublicationRelations(pubid,
+														 PUBLICATION_PART_ROOT);
+
+		/*
+		 * Check that the statement does not contradict itself.  SET replaces
+		 * the EXCEPT list rather than adding to it, so the entries the
+		 * publication currently holds cannot be taken as agreement: one of
+		 * them may be dropped below.
+		 */
+		CheckExceptConflicts(pubid, except_rels, cross_schema_children,
+							 explicitrelids, schemaidlist,
+							 false /* existing_except_kept */ );
+
+
+		/*
+		 * Get the current set of EXCEPT entries.  Only FOR ALL TABLES and
+		 * schema-level publications can have EXCEPT entries; for any other
+		 * publication type oldexceptrelids stays NIL.
+		 */
+		if (GetPublication(pubid)->alltables || is_schema_publication(pubid))
+			oldexceptrelids = GetExcludedPublicationTables(pubid,
+														   PUBLICATION_PART_ROOT);
+		/* Build a list of old EXCEPT entries not present in the new list. */
+		foreach_oid(oldrelid, oldexceptrelids)
+		{
+			if (!list_member_oid(newexceptrelids, oldrelid))
+				delrelids = lappend_oid(delrelids, oldrelid);
+		}
+
+		/* Drop old EXCEPT entries not present in the new list. */
+		foreach_oid(relid, delrelids)
+		{
+			Oid			proid;
+			ObjectAddress obj;
+
+			proid = GetSysCacheOid2(PUBLICATIONRELMAP,
+									Anum_pg_publication_rel_oid,
+									ObjectIdGetDatum(relid),
+									ObjectIdGetDatum(pubid));
+			if (OidIsValid(proid))
+			{
+				ObjectAddressSet(obj, PublicationRelRelationId, proid);
+				performDeletion(&obj, DROP_CASCADE, 0);
+			}
+		}
+
+		/*
+		 * Add the new EXCEPT entries.  if_not_exists is true here because an
+		 * entry present in both the old and the new list is not dropped above
+		 * and so is still there.
+		 */
+		PublicationAddTables(pubid, except_rels, true, stmt);
+
+		CloseTableList(except_rels);
 	}
 }
 
@@ -2386,17 +2490,17 @@ OpenTableList(List *tables, List **cross_schema_children)
 				CHECK_FOR_INTERRUPTS();
 
 				/*
-				 * An EXCEPT clause also excludes inheritance children in other
-				 * schemas. Keep track of such children separately so the
-				 * caller can detect if the same statement also includes them
-				 * through another table or schema clause.
+				 * An EXCEPT clause also excludes inheritance children in
+				 * other schemas. Keep track of such children separately so
+				 * the caller can detect if the same statement also includes
+				 * them through another table or schema clause.
 				 */
 				if (t->except_in_schema &&
 					get_rel_namespace(childrelid) != parentnsp)
 				{
 					if (cross_schema_children)
 						*cross_schema_children = lappend_oid(*cross_schema_children,
-														childrelid);
+															 childrelid);
 					continue;
 				}
 
@@ -2540,33 +2644,69 @@ PublicationAddTables(Oid pubid, List *rels, bool if_not_exists,
 
 /*
  * Remove listed tables from the publication.
+ *
+ * delete_excluded controls how EXCEPT (prexcept=true) rows are handled:
+ *   true  - delete them silently.  Used by the SET path to clear stale
+ *           EXCEPT rows when ALTER PUBLICATION ... SET replaces or removes
+ *           the EXCEPT clause.
+ *   false - treat them as "not a member". Used by ALTER ... DROP TABLE: an
+ *           EXCEPT entry is not something DROP TABLE is expected to remove.
  */
 static void
-PublicationDropTables(Oid pubid, List *rels, bool missing_ok)
+PublicationDropTables(Oid pubid, List *rels, bool missing_ok,
+					  bool delete_excluded)
 {
 	ObjectAddress obj;
 	ListCell   *lc;
-	Oid			prid;
 
 	foreach(lc, rels)
 	{
 		PublicationRelInfo *pubrel = (PublicationRelInfo *) lfirst(lc);
 		Relation	rel = pubrel->relation;
 		Oid			relid = RelationGetRelid(rel);
+		HeapTuple	tup;
+		Form_pg_publication_rel pubrelform;
+		Oid			prid = InvalidOid;
+		bool		is_except = false;
 
 		if (pubrel->columns)
 			ereport(ERROR,
 					errcode(ERRCODE_SYNTAX_ERROR),
 					errmsg("column list must not be specified in ALTER PUBLICATION ... DROP"));
 
-		prid = GetSysCacheOid2(PUBLICATIONRELMAP, Anum_pg_publication_rel_oid,
-							   ObjectIdGetDatum(relid),
-							   ObjectIdGetDatum(pubid));
-		if (!OidIsValid(prid))
+		tup = SearchSysCache2(PUBLICATIONRELMAP,
+							  ObjectIdGetDatum(relid),
+							  ObjectIdGetDatum(pubid));
+		if (HeapTupleIsValid(tup))
+		{
+			pubrelform = (Form_pg_publication_rel) GETSTRUCT(tup);
+			is_except = pubrelform->prexcept;
+			prid = pubrelform->oid;
+			ReleaseSysCache(tup);
+		}
+
+		/*
+		 * DROP TABLE operates only on regular member rows.  A missing row and
+		 * an EXCEPT row both mean the table isn't currently published, but we
+		 * distinguish them so the EXCEPT case can carry a hint pointing the
+		 * user at the EXCEPT clause.  The SET-cleanup caller (delete_excluded
+		 * = true) intends to remove EXCEPT rows and so bypasses the EXCEPT
+		 * half of this check.
+		 */
+		if (!OidIsValid(prid) || (is_except && !delete_excluded))
 		{
 			if (missing_ok)
 				continue;
 
+			if (is_except)
+				ereport(ERROR,
+						(errcode(ERRCODE_UNDEFINED_OBJECT),
+						 errmsg("cannot drop table \"%s\" from publication \"%s\"",
+								RelationGetQualifiedRelationName(rel),
+								get_publication_name(pubid, false)),
+						 errdetail("The table is currently named in an EXCEPT clause of the publication."),
+						 errhint("Change the EXCEPT clause using ALTER PUBLICATION ... SET TABLES IN SCHEMA ... EXCEPT.")));
+
 			ereport(ERROR,
 					(errcode(ERRCODE_UNDEFINED_OBJECT),
 					 errmsg("relation \"%s\" is not part of the publication",
@@ -2622,6 +2762,7 @@ PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok)
 	foreach(lc, schemas)
 	{
 		Oid			schemaid = lfirst_oid(lc);
+		List	   *except_relids;
 
 		psid = GetSysCacheOid2(PUBLICATIONNAMESPACEMAP,
 							   Anum_pg_publication_namespace_oid,
@@ -2638,8 +2779,40 @@ PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok)
 							get_namespace_name(schemaid))));
 		}
 
+		/*
+		 * Collect EXCEPT entries for tables belonging to this schema before
+		 * removing the schema entry.
+		 */
+		except_relids = GetExcludedPublicationTables(pubid, PUBLICATION_PART_ROOT);
+
 		ObjectAddressSet(obj, PublicationNamespaceRelationId, psid);
 		performDeletion(&obj, DROP_CASCADE, 0);
+
+		/*
+		 * Drop any prexcept rows for tables belonging to this schema. These
+		 * rows have no pg_depend entry pointing at the
+		 * pg_publication_namespace row, so they are not cascaded by the
+		 * performDeletion() call above and must be cleaned up explicitly.
+		 */
+		foreach_oid(relid, except_relids)
+		{
+			Oid			proid;
+
+			if (get_rel_namespace(relid) != schemaid)
+				continue;
+
+			proid = GetSysCacheOid2(PUBLICATIONRELMAP,
+									Anum_pg_publication_rel_oid,
+									ObjectIdGetDatum(relid),
+									ObjectIdGetDatum(pubid));
+			if (OidIsValid(proid))
+			{
+				ObjectAddressSet(obj, PublicationRelRelationId, proid);
+				performDeletion(&obj, DROP_CASCADE, 0);
+			}
+		}
+
+		list_free(except_relids);
 	}
 }
 
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 6d857865d6b..d7a083ea871 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -2376,6 +2376,23 @@ match_previous_words(int pattern_id,
 	}
 	else if (Matches("ALTER", "PUBLICATION", MatchAny, "ADD", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", "TABLE", MatchAnyN) && !ends_with(prev_wd, ','))
 		COMPLETE_WITH(")");
+	/* After a single schema name in SET context, offer EXCEPT ( TABLE */
+	else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny) &&
+			 !ends_with(prev_wd, ','))
+		COMPLETE_WITH("EXCEPT ( TABLE");
+	else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT"))
+		COMPLETE_WITH("( TABLE");
+	else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "("))
+		COMPLETE_WITH("TABLE");
+	else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", "CURRENT_SCHEMA", "EXCEPT", "(", "TABLE"))
+		COMPLETE_WITH_QUERY_VERBATIM(Query_for_list_of_tables_in_current_schema);
+	else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", "TABLE"))
+	{
+		set_completion_reference(prev4_wd);
+		COMPLETE_WITH_QUERY_VERBATIM(Query_for_list_of_tables_in_schema);
+	}
+	else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", "TABLE", MatchAnyN) && !ends_with(prev_wd, ','))
+		COMPLETE_WITH(")");
 	/* ALTER PUBLICATION <name> SET ( */
 	else if (Matches("ALTER", "PUBLICATION", MatchAny, MatchAnyN, "SET", "("))
 		COMPLETE_WITH("publish", "publish_generated_columns", "publish_via_partition_root");
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 5ef61587a15..6a851d044cd 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -671,14 +671,14 @@ DROP PUBLICATION testpub_except_alter1;
 CREATE PUBLICATION testpub_except_alter2 FOR TABLE pub_test.testpub_tbl_s1;
 ALTER PUBLICATION testpub_except_alter2
     SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
-ERROR:  EXCEPT clause is not supported with SET in ALTER PUBLICATION
-HINT:  Drop and re-add the schema with the desired EXCEPT list.
 \dRp+ testpub_except_alter2
                                                       Publication testpub_except_alter2
           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:
+Tables from schemas:
+    "pub_test"
+Except tables:
     "pub_test.testpub_tbl_s1"
 
 DROP PUBLICATION testpub_except_alter2;
@@ -753,6 +753,52 @@ Except tables:
     "pub_test.testpub_inh_sibling"
     "public.testpub_inh_child"
 
+DROP PUBLICATION testpub_inh;
+-- SET replaces the EXCEPT list rather than adding to it, so an entry the
+-- publication currently holds cannot excuse a conflict: it may be one that this
+-- same statement drops.
+CREATE PUBLICATION testpub_inh
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent),
+        TABLES IN SCHEMA public EXCEPT (TABLE testpub_inh_child);
+-- fail: dropping public's EXCEPT clause would leave testpub_inh_child published
+-- while pub_test's clause still covers it
+ALTER PUBLICATION testpub_inh
+    SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent),
+        TABLES IN SCHEMA public;
+ERROR:  table "public.testpub_inh_child" cannot be both published and excluded
+DETAIL:  It inherits from a table named in the EXCEPT clause of another schema, and its own schema "public" is published in full.
+HINT:  Use ONLY in the EXCEPT clause to exclude just the parent, or arrange for schema "public" to exclude this table as well.
+-- ok: the new EXCEPT list still excludes it, so both clauses agree
+ALTER PUBLICATION testpub_inh
+    SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent),
+        TABLES IN SCHEMA public EXCEPT (TABLE testpub_inh_child);
+\dRp+ testpub_inh
+                                                           Publication testpub_inh
+          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_test"
+    "public"
+Except tables:
+    "pub_test.testpub_inh_parent"
+    "pub_test.testpub_inh_sibling"
+    "public.testpub_inh_child"
+
+-- ok: public is no longer published, so nothing is both published and excluded
+ALTER PUBLICATION testpub_inh
+    SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent);
+\dRp+ testpub_inh
+                                                           Publication testpub_inh
+          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_test"
+Except tables:
+    "pub_test.testpub_inh_parent"
+    "pub_test.testpub_inh_sibling"
+
 DROP PUBLICATION testpub_inh;
 DROP TABLE testpub_inh_child, pub_test.testpub_inh_sibling,
            pub_test.testpub_inh_parent;
@@ -814,6 +860,115 @@ Except tables:
     "pub_test.testpub_tbl_s1"
     "pub_test.testpub_tbl_s2"
 
+-- SET: replace the except list (keep same schema, different except table)
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2);
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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_test"
+Except tables:
+    "pub_test.testpub_tbl_s2"
+
+-- fail: table in EXCEPT clause also appears in the explicit table list
+ALTER PUBLICATION testpub_alter_except SET TABLE pub_test.testpub_tbl_s1, TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+ERROR:  table "pub_test.testpub_tbl_s1" cannot be both published and excluded
+-- fail: explicit partition + EXCEPT-ed root, given in a single SET statement
+ALTER PUBLICATION testpub_alter_except SET TABLE pub_test.testpub_part_s, TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s);
+ERROR:  partition "pub_test.testpub_part_s" cannot be both published and excluded
+DETAIL:  Partition root "pub_test.testpub_parted_s" is named in the publication's EXCEPT clause for schema "pub_test".
+-- fail: except table's schema (public) not in the publication's schema list (pub_test)
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE public.testpub_tbl1);
+ERROR:  table "public.testpub_tbl1" in EXCEPT clause does not belong to schema "pub_test"
+LINE 1: ...xcept SET TABLES IN SCHEMA pub_test EXCEPT (TABLE public.tes...
+                                                             ^
+-- SET: unqualified name in EXCEPT is implicitly qualified with the schema
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE testpub_tbl_s1);
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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_test"
+Except tables:
+    "pub_test.testpub_tbl_s1"
+
+-- SET without EXCEPT clears the existing except list
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test;
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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_test"
+
+-- SET to a different schema removes old schema's EXCEPT entries
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE testpub_tbl_s1);
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA public;
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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:
+    "public"
+
+-- fail: nonexistent table in EXCEPT clause (SET path)
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.nonexistent_table);
+ERROR:  relation "pub_test.nonexistent_table" does not exist
+-- SET: multiple schemas each with their own EXCEPT clause
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                                                                      public EXCEPT (TABLE testpub_tbl1);
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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_test"
+    "public"
+Except tables:
+    "pub_test.testpub_tbl_s1"
+    "public.testpub_tbl1"
+
+-- fail: ALTER PUBLICATION ... DROP TABLE on an excluded table is rejected
+-- with an EXCEPT-specific error.
+ALTER PUBLICATION testpub_alter_except DROP TABLE pub_test.testpub_tbl_s1;
+ERROR:  cannot drop table "pub_test.testpub_tbl_s1" from publication "testpub_alter_except"
+DETAIL:  The table is currently named in an EXCEPT clause of the publication.
+HINT:  Change the EXCEPT clause using ALTER PUBLICATION ... SET TABLES IN SCHEMA ... EXCEPT.
+-- the EXCEPT entry is still present after the rejected DROP TABLE
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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_test"
+    "public"
+Except tables:
+    "pub_test.testpub_tbl_s1"
+    "public.testpub_tbl1"
+
+-- fail: EXCEPT is not allowed with DROP
+ALTER PUBLICATION testpub_alter_except DROP TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2);
+ERROR:  EXCEPT clause is not supported with DROP in ALTER PUBLICATION
+-- DROP TABLES IN SCHEMA removes associated EXCEPT entries
+ALTER PUBLICATION testpub_alter_except DROP TABLES IN SCHEMA pub_test;
+\dRp+ testpub_alter_except
+                                                       Publication testpub_alter_except
+          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:
+    "public"
+Except tables:
+    "public.testpub_tbl1"
+
 -- Cleanup
 RESET client_min_messages;
 DROP TABLE pub_test.testpub_tbl_s1, pub_test.testpub_tbl_s2;
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index 005d40c5c82..64b5d3c3864 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -383,6 +383,28 @@ CREATE PUBLICATION testpub_inh
         TABLES IN SCHEMA public EXCEPT (TABLE testpub_inh_child);
 \dRp+ testpub_inh
 DROP PUBLICATION testpub_inh;
+
+-- SET replaces the EXCEPT list rather than adding to it, so an entry the
+-- publication currently holds cannot excuse a conflict: it may be one that this
+-- same statement drops.
+CREATE PUBLICATION testpub_inh
+    FOR TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent),
+        TABLES IN SCHEMA public EXCEPT (TABLE testpub_inh_child);
+-- fail: dropping public's EXCEPT clause would leave testpub_inh_child published
+-- while pub_test's clause still covers it
+ALTER PUBLICATION testpub_inh
+    SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent),
+        TABLES IN SCHEMA public;
+-- ok: the new EXCEPT list still excludes it, so both clauses agree
+ALTER PUBLICATION testpub_inh
+    SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent),
+        TABLES IN SCHEMA public EXCEPT (TABLE testpub_inh_child);
+\dRp+ testpub_inh
+-- ok: public is no longer published, so nothing is both published and excluded
+ALTER PUBLICATION testpub_inh
+    SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_inh_parent);
+\dRp+ testpub_inh
+DROP PUBLICATION testpub_inh;
 DROP TABLE testpub_inh_child, pub_test.testpub_inh_sibling,
            pub_test.testpub_inh_parent;
 
@@ -429,6 +451,53 @@ ALTER PUBLICATION testpub_alter_except DROP TABLE pub_test.testpub_parted_s;
 ALTER PUBLICATION testpub_alter_except ADD TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1, testpub_tbl_s2);
 \dRp+ testpub_alter_except
 
+-- SET: replace the except list (keep same schema, different except table)
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2);
+\dRp+ testpub_alter_except
+
+-- fail: table in EXCEPT clause also appears in the explicit table list
+ALTER PUBLICATION testpub_alter_except SET TABLE pub_test.testpub_tbl_s1, TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1);
+
+-- fail: explicit partition + EXCEPT-ed root, given in a single SET statement
+ALTER PUBLICATION testpub_alter_except SET TABLE pub_test.testpub_part_s, TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_parted_s);
+
+-- fail: except table's schema (public) not in the publication's schema list (pub_test)
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE public.testpub_tbl1);
+
+-- SET: unqualified name in EXCEPT is implicitly qualified with the schema
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE testpub_tbl_s1);
+\dRp+ testpub_alter_except
+
+-- SET without EXCEPT clears the existing except list
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test;
+\dRp+ testpub_alter_except
+
+-- SET to a different schema removes old schema's EXCEPT entries
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE testpub_tbl_s1);
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA public;
+\dRp+ testpub_alter_except
+
+-- fail: nonexistent table in EXCEPT clause (SET path)
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.nonexistent_table);
+
+-- SET: multiple schemas each with their own EXCEPT clause
+ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s1),
+                                                                      public EXCEPT (TABLE testpub_tbl1);
+\dRp+ testpub_alter_except
+
+-- fail: ALTER PUBLICATION ... DROP TABLE on an excluded table is rejected
+-- with an EXCEPT-specific error.
+ALTER PUBLICATION testpub_alter_except DROP TABLE pub_test.testpub_tbl_s1;
+-- the EXCEPT entry is still present after the rejected DROP TABLE
+\dRp+ testpub_alter_except
+
+-- fail: EXCEPT is not allowed with DROP
+ALTER PUBLICATION testpub_alter_except DROP TABLES IN SCHEMA pub_test EXCEPT (TABLE pub_test.testpub_tbl_s2);
+
+-- DROP TABLES IN SCHEMA removes associated EXCEPT entries
+ALTER PUBLICATION testpub_alter_except DROP TABLES IN SCHEMA pub_test;
+\dRp+ testpub_alter_except
+
 -- Cleanup
 RESET client_min_messages;
 DROP TABLE pub_test.testpub_tbl_s1, pub_test.testpub_tbl_s2;
diff --git a/src/test/subscription/t/037_except.pl b/src/test/subscription/t/037_except.pl
index ed931d0d970..f981bcd0003 100644
--- a/src/test/subscription/t/037_except.pl
+++ b/src/test/subscription/t/037_except.pl
@@ -376,6 +376,61 @@ $result =
 is($result, qq(0),
 	'ALTER ... ADD TABLES IN SCHEMA EXCEPT: excluded table not synced');
 
+# SET: replace the except list; tab_excluded is now included and tab_published is excluded.
+$node_publisher->safe_psql('postgres',
+	"ALTER PUBLICATION sch_pub SET TABLES IN SCHEMA sch1 EXCEPT (TABLE sch1.tab_published)"
+);
+$node_subscriber->safe_psql('postgres',
+	"ALTER SUBSCRIPTION sch_sub REFRESH PUBLICATION");
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sch_sub');
+
+$node_publisher->safe_psql(
+	'postgres', qq(
+	INSERT INTO sch1.tab_published VALUES (7);
+	INSERT INTO sch1.tab_excluded VALUES (7);
+));
+$node_publisher->wait_for_catchup('sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_excluded WHERE a = 7");
+is($result, qq(1),
+	'ALTER ... SET TABLES IN SCHEMA EXCEPT: newly included table is replicated'
+);
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_published WHERE a = 7");
+is($result, qq(0),
+	'ALTER ... SET TABLES IN SCHEMA EXCEPT: now-excluded table is not replicated'
+);
+
+# SET without EXCEPT: clears the except list; both tables are now published.
+$node_publisher->safe_psql('postgres',
+	"ALTER PUBLICATION sch_pub SET TABLES IN SCHEMA sch1");
+$node_subscriber->safe_psql('postgres',
+	"ALTER SUBSCRIPTION sch_sub REFRESH PUBLICATION");
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'sch_sub');
+
+$node_publisher->safe_psql(
+	'postgres', qq(
+	INSERT INTO sch1.tab_published VALUES (8);
+	INSERT INTO sch1.tab_excluded VALUES (8);
+));
+$node_publisher->wait_for_catchup('sch_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_published WHERE a = 8");
+is($result, qq(1),
+	'ALTER ... SET TABLES IN SCHEMA (no EXCEPT): tab_published replicated after except list cleared'
+);
+$result =
+  $node_subscriber->safe_psql('postgres',
+	"SELECT count(*) FROM sch1.tab_excluded WHERE a = 8");
+is($result, qq(1),
+	'ALTER ... SET TABLES IN SCHEMA (no EXCEPT): tab_excluded replicated after except list cleared'
+);
+
 $node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sch_sub');
 $node_publisher->safe_psql('postgres', 'DROP PUBLICATION sch_pub');
 
@@ -571,6 +626,36 @@ $node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub_sch_without_exc
 $node_publisher->safe_psql('postgres', 'DROP SCHEMA msch CASCADE');
 $node_subscriber->safe_psql('postgres', 'DROP SCHEMA msch CASCADE');
 
+# OK when a table is excluded by a TABLES IN SCHEMA EXCEPT publication,
+# but is included by another publication.
+$node_publisher->safe_psql('postgres', 'TRUNCATE tab1');
+$node_subscriber->safe_psql('postgres', 'TRUNCATE tab1');
+
+$node_publisher->safe_psql(
+	'postgres', qq(
+	CREATE PUBLICATION tap_pub1 FOR TABLES IN SCHEMA public EXCEPT (TABLE public.tab1);
+	CREATE PUBLICATION tap_pub2 FOR TABLE tab1;
+	INSERT INTO tab1 VALUES(1);
+));
+$node_subscriber->psql('postgres',
+	"CREATE SUBSCRIPTION tap_sub CONNECTION '$publisher_connstr' PUBLICATION tap_pub1, tap_pub2"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'tap_sub');
+
+$node_publisher->safe_psql('postgres', qq(INSERT INTO tab1 VALUES(2)));
+$node_publisher->wait_for_catchup('tap_sub');
+
+$result =
+  $node_subscriber->safe_psql('postgres', "SELECT * FROM tab1 ORDER BY a");
+is( $result, qq(1
+2),
+	"TABLES IN SCHEMA EXCEPT: table excluded in schema pub but included by another pub is replicated"
+);
+
+$node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION tap_sub');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub1');
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub2');
+
 $node_publisher->stop('fast');
 
 done_testing();
-- 
2.55.0

