From 8576fe57f11c9224e28b7fd365134d8ce5a26c6b Mon Sep 17 00:00:00 2001 From: Nisha Moond Date: Tue, 14 Apr 2026 11:34:07 +0530 Subject: [PATCH v2 3/3] Add EXCEPT support to ALTER PUBLICATION SET TABLES IN SCHEMA Extend AlterPublicationExceptTables() with the AP_SetObjects case, which redefine the publication and replaces the entire EXCEPT list. Syntax: ALTER PUBLICATION pub SET TABLES IN SCHEMA s EXCEPT (s.t1, s.t2); --- doc/src/sgml/ref/alter_publication.sgml | 40 +++++- src/backend/catalog/pg_publication.c | 2 +- src/backend/commands/publicationcmds.c | 156 ++++++++++++++++++++-- src/bin/psql/tab-complete.in.c | 26 ++++ src/test/regress/expected/publication.out | 23 ++++ src/test/regress/sql/publication.sql | 12 ++ src/test/subscription/t/037_except.pl | 90 +++++++++++++ 7 files changed, 328 insertions(+), 21 deletions(-) diff --git a/doc/src/sgml/ref/alter_publication.sgml b/doc/src/sgml/ref/alter_publication.sgml index e901163a89b..c59bf293a59 100644 --- a/doc/src/sgml/ref/alter_publication.sgml +++ b/doc/src/sgml/ref/alter_publication.sgml @@ -97,7 +97,11 @@ ALTER PUBLICATION name RENAME TO FOR TABLE or FOR TABLES IN SCHEMA, replaces the list of tables/schemas in the publication with the specified list; the existing tables or schemas - that were present in the publication will be removed. + that were present in the publication will be removed. When + SET TABLES IN SCHEMA is used with an + EXCEPT clause, the excluded tables for each schema are + replaced with the specified list; if EXCEPT is omitted + for a schema, any existing exclusions for that schema are cleared. @@ -116,10 +120,15 @@ ALTER PUBLICATION name RENAME TO The EXCEPT clause can be used with - ADD TABLES IN SCHEMA to exclude specific tables from a + ADD TABLES IN SCHEMA or + SET TABLES IN SCHEMA to exclude specific tables from a schema-level publication. Each excluded table must belong to the schema named in the same TABLES IN SCHEMA clause and must be - schema-qualified. EXCEPT is not supported with + schema-qualified. With ADD, the listed tables are added + to the exclusion list for that schema. With SET, the + exclusion list for each named schema is replaced by the specified tables; + omitting EXCEPT for a schema clears its exclusion list + entirely. EXCEPT is not supported with DROP TABLES IN SCHEMA; instead, dropping a schema from the publication automatically removes all of its associated EXCEPT entries. @@ -218,10 +227,16 @@ ALTER PUBLICATION name RENAME TO Specifies tables to be excluded from a schema-level publication entry. - This clause may be used with ADD TABLES IN SCHEMA - and not with DROP TABLES IN SCHEMA. Each named - table must belong to the schema specified in the same TABLES IN SCHEMA - clause and must be schema-qualified. See + This clause may be used with ADD TABLES IN SCHEMA or + SET TABLES IN SCHEMA, but not with + DROP TABLES IN SCHEMA. Each named table must belong + to the schema specified in the same TABLES IN SCHEMA + clause and must be schema-qualified. When used with + ADD, the tables are appended to the existing exclusion + list for that schema. When used with SET, the + exclusion list for that schema is replaced; omitting + EXCEPT clears the exclusion list for that schema. + See for further details on the semantics of EXCEPT. @@ -327,6 +342,17 @@ ALTER PUBLICATION sales_publication ADD TABLES IN SCHEMA sales EXCEPT (sales.int + + Replace the schema list of sales_publication with + sales, excluding only + sales.drafts (any previously excluded tables for + that schema are replaced, and schemas previously in the publication are + removed): + +ALTER PUBLICATION sales_publication SET TABLES IN SCHEMA sales EXCEPT (sales.drafts); + + + Add tables users, departments and schema diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c index 85c2528873f..e63f221f1af 100644 --- a/src/backend/catalog/pg_publication.c +++ b/src/backend/catalog/pg_publication.c @@ -1002,7 +1002,7 @@ GetIncludedPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt) List * GetExcludedPublicationTables(Oid pubid, PublicationPartOpt pub_partopt) { - Assert(GetPublication(pubid)->alltables); + Assert(GetPublication(pubid)->alltables || is_schema_publication(pubid)); return get_publication_relations(pubid, pub_partopt, true); } diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c index b26706b5d1d..de20233062b 100644 --- a/src/backend/commands/publicationcmds.c +++ b/src/backend/commands/publicationcmds.c @@ -1598,9 +1598,11 @@ AlterPublicationExceptTables(AlterPublicationStmt *stmt, Oid pubid = pubform->oid; /* - * Nothing to do if no EXCEPT entries. + * Nothing to do if no EXCEPT entries, except in SET: for that it is quite + * possible that the user has removed all exceptions, in which case we + * need to drop any existing ones. */ - if (!exceptrelations) + if (!exceptrelations && stmt->action != AP_SetObjects) return; /* @@ -1622,17 +1624,6 @@ AlterPublicationExceptTables(AlterPublicationStmt *stmt, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("EXCEPT clause is not supported with DROP in ALTER PUBLICATION"))); - /* - * EXCEPT with SET is not supported: SET replaces the schema list but does - * not have a well-defined semantics for merging or replacing existing - * except entries. Users should DROP and re-ADD the schema with the - * desired EXCEPT list instead. - */ - if (stmt->action == AP_SetObjects) - ereport(ERROR, - (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), - errmsg("EXCEPT clause is not supported with SET in ALTER PUBLICATION"))); - if (stmt->action == AP_AddObjects) { List *rels; @@ -1676,6 +1667,106 @@ AlterPublicationExceptTables(AlterPublicationStmt *stmt, PublicationAddTables(pubid, rels, false, stmt); + CloseTableList(rels); + } + else /* AP_SetObjects */ + { + List *oldexceptrelids = NIL; + List *newexceptrelids = NIL; + List *delrelids = NIL; + List *rels; + List *explicitrelids; + List *pubschemaidlist; + ListCell *lc; + + rels = OpenTableList(exceptrelations); + + /* Collect OIDs of the desired new except list. */ + foreach(lc, rels) + { + PublicationRelInfo *pri = (PublicationRelInfo *) lfirst(lc); + + newexceptrelids = lappend_oid(newexceptrelids, + RelationGetRelid(pri->relation)); + } + + /* + * Build the effective schema list: schemas already in the publication + * plus any being set in this same command. The latter may not yet be + * visible via a catalog scan, so we include them explicitly. + */ + pubschemaidlist = GetPublicationSchemas(pubid); + pubschemaidlist = list_concat_unique_oid(pubschemaidlist, schemaidlist); + explicitrelids = GetIncludedPublicationRelations(pubid, + PUBLICATION_PART_ROOT); + + foreach(lc, rels) + { + PublicationRelInfo *pri = (PublicationRelInfo *) lfirst(lc); + Oid relid = RelationGetRelid(pri->relation); + Oid relns = RelationGetNamespace(pri->relation); + + if (!list_member_oid(pubschemaidlist, relns)) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("table \"%s.%s\" in EXCEPT clause does not belong to any schema listed in the publication", + get_namespace_name(relns), + RelationGetRelationName(pri->relation))); + + if (list_member_oid(explicitrelids, relid)) + ereport(ERROR, + errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("table \"%s.%s\" cannot appear in both the table list and the EXCEPT clause", + get_namespace_name(relns), + RelationGetRelationName(pri->relation))); + } + + /* + * 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. + * + * Note: we check is_schema_publication() against the current catalog + * state (before AlterPublicationSchemas has run), so if the caller is + * doing SET TABLE t1 to convert a schema publication into a plain + * table publication, is_schema_publication() still returns true here. + * That is intentional: it lets us discover and clean up any stale + * except entries that belong to the old schema definition. + */ + 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(lc, oldexceptrelids) + { + Oid oldrelid = lfirst_oid(lc); + + if (!list_member_oid(newexceptrelids, oldrelid)) + delrelids = lappend_oid(delrelids, oldrelid); + } + + /* Drop old except entries not present in the new list. */ + foreach(lc, delrelids) + { + Oid relid = lfirst_oid(lc); + Oid proid; + ObjectAddress obj; + + proid = GetSysCacheOid2(PUBLICATIONRELMAP, + Anum_pg_publication_rel_oid, + ObjectIdGetDatum(relid), + ObjectIdGetDatum(pubid)); + if (!OidIsValid(proid)) + continue; /* already gone */ + + ObjectAddressSet(obj, PublicationRelRelationId, proid); + performDeletion(&obj, DROP_CASCADE, 0); + } + + /* Add new except entries, skipping any already present. */ + PublicationAddTables(pubid, rels, true, stmt); + CloseTableList(rels); } } @@ -2330,6 +2421,8 @@ PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok) foreach(lc, schemas) { Oid schemaid = lfirst_oid(lc); + List *exceptoids; + ListCell *elc; psid = GetSysCacheOid2(PUBLICATIONNAMESPACEMAP, Anum_pg_publication_namespace_oid, @@ -2346,8 +2439,45 @@ PublicationDropSchemas(Oid pubid, List *schemas, bool missing_ok) get_namespace_name(schemaid)))); } + /* + * Collect prexcept rows for tables belonging to this schema before + * removing the schema entry. GetExcludedPublicationTables relies on + * is_schema_publication(), which scans pg_publication_namespace; if + * this is the last schema in the publication, performDeletion() below + * would remove that row and make is_schema_publication() return + * false, tripping the assertion. + */ + exceptoids = 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(elc, exceptoids) + { + Oid relid = lfirst_oid(elc); + Oid proid; + + if (get_rel_namespace(relid) != schemaid) + continue; + + proid = GetSysCacheOid2(PUBLICATIONRELMAP, + Anum_pg_publication_rel_oid, + ObjectIdGetDatum(relid), + ObjectIdGetDatum(pubid)); + if (!OidIsValid(proid)) + continue; /* already gone */ + + ObjectAddressSet(obj, PublicationRelRelationId, proid); + performDeletion(&obj, DROP_CASCADE, 0); + } + + list_free(exceptoids); } } diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c index 15b7551dcdc..a41e1d4a4a5 100644 --- a/src/bin/psql/tab-complete.in.c +++ b/src/bin/psql/tab-complete.in.c @@ -2389,6 +2389,32 @@ match_previous_words(int pattern_id, } else if (Matches("ALTER", "PUBLICATION", MatchAny, "ADD", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", MatchAnyN) && !ends_with(prev_wd, ',')) COMPLETE_WITH(")"); + /* After a single schema name in SET context, offer EXCEPT ( */ + else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny) && + !ends_with(prev_wd, ',')) + COMPLETE_WITH("EXCEPT ("); + else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT")) + COMPLETE_WITH("("); + else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(")) + { + if (strchr(prev3_wd, ',') == NULL) + { + set_completion_reference(prev3_wd); + COMPLETE_WITH_QUERY_VERBATIM(Query_for_list_of_tables_in_schema); + } + } + else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", MatchAnyN) && ends_with(prev_wd, ',')) + { + char *schema_word = previous_words[previous_words_count - 8]; + + if (strchr(schema_word, ',') == NULL) + { + set_completion_reference(schema_word); + COMPLETE_WITH_QUERY_VERBATIM(Query_for_list_of_tables_in_schema); + } + } + else if (Matches("ALTER", "PUBLICATION", MatchAny, "SET", "TABLES", "IN", "SCHEMA", MatchAny, "EXCEPT", "(", MatchAnyN) && !ends_with(prev_wd, ',')) + COMPLETE_WITH(")"); /* ALTER PUBLICATION 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 e563553e8eb..9526459f65a 100644 --- a/src/test/regress/expected/publication.out +++ b/src/test/regress/expected/publication.out @@ -517,6 +517,29 @@ Tables from schemas: Except tables: "pub_test.testpub_tbl_s1" +-- SET: replace the except list (keep same schema, different except table) +ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (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" + +-- error: EXCEPT is not allowed with DROP +ALTER PUBLICATION testpub_alter_except DROP TABLES IN SCHEMA pub_test EXCEPT (pub_test.testpub_tbl_s2); +ERROR: EXCEPT clause is not supported with DROP in ALTER PUBLICATION +-- error: except table must be schema-qualified +ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (testpub_tbl_s1); +ERROR: table "testpub_tbl_s1" in EXCEPT clause must be schema-qualified +LINE 1: ...lter_except SET TABLES IN SCHEMA pub_test EXCEPT (testpub_tb... + ^ +-- error: 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 (public.testpub_tbl1); +ERROR: table "public.testpub_tbl1" in EXCEPT clause does not belong to any schema listed in the publication RESET client_min_messages; DROP TABLE pub_test.testpub_tbl_s1, pub_test.testpub_tbl_s2; DROP PUBLICATION testpub_schema_except1, testpub_schema_except2, testpub_alter_except; diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql index 3436b832eca..6de78800aa5 100644 --- a/src/test/regress/sql/publication.sql +++ b/src/test/regress/sql/publication.sql @@ -249,6 +249,18 @@ CREATE PUBLICATION testpub_alter_except; ALTER PUBLICATION testpub_alter_except ADD TABLES IN SCHEMA pub_test EXCEPT (pub_test.testpub_tbl_s1); \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 (pub_test.testpub_tbl_s2); +\dRp+ testpub_alter_except + +-- error: EXCEPT is not allowed with DROP +ALTER PUBLICATION testpub_alter_except DROP TABLES IN SCHEMA pub_test EXCEPT (pub_test.testpub_tbl_s2); + +-- error: except table must be schema-qualified +ALTER PUBLICATION testpub_alter_except SET TABLES IN SCHEMA pub_test EXCEPT (testpub_tbl_s1); +-- error: 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 (public.testpub_tbl1); + RESET client_min_messages; DROP TABLE pub_test.testpub_tbl_s1, pub_test.testpub_tbl_s2; DROP PUBLICATION testpub_schema_except1, testpub_schema_except2, testpub_alter_except; diff --git a/src/test/subscription/t/037_except.pl b/src/test/subscription/t/037_except.pl index 9675db18bef..e622bfb3d7f 100644 --- a/src/test/subscription/t/037_except.pl +++ b/src/test/subscription/t/037_except.pl @@ -378,6 +378,66 @@ $result = is($result, qq(0), 'ALTER ... ADD TABLES IN SCHEMA EXCEPT: excluded table not synced'); +# SET: replace the except list; tab_exc is now included and tab_pub is excluded. +$node_publisher->safe_psql('postgres', + "ALTER PUBLICATION sch_pub SET TABLES IN SCHEMA sch1 EXCEPT (sch1.tab_pub)" +); +$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_pub VALUES (7); + INSERT INTO sch1.tab_exc VALUES (7); +)); +$node_publisher->wait_for_catchup('sch_sub'); + +$result = + $node_subscriber->safe_psql('postgres', + "SELECT count(*) FROM sch1.tab_exc"); +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_pub"); +is($result, qq(6), + 'ALTER ... SET TABLES IN SCHEMA EXCEPT: now-excluded table is not replicated' +); + +# SET without EXCEPT: clears the except list; both tables are now published. +# tab_pub will be re-synced because REFRESH removed its entry when it was +# excluded. Truncate the subscriber copy beforehand so the re-sync produces +# a predictable count: publisher has 7 rows (6 original + INSERT(7)), so the +# subscriber ends up with 7 after re-sync, then 8 after INSERT(8). +$node_subscriber->safe_psql('postgres', 'TRUNCATE sch1.tab_pub'); +$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_pub VALUES (8); + INSERT INTO sch1.tab_exc VALUES (8); +)); +$node_publisher->wait_for_catchup('sch_sub'); + +$result = + $node_subscriber->safe_psql('postgres', + "SELECT count(*) FROM sch1.tab_pub"); +is($result, qq(8), + 'ALTER ... SET TABLES IN SCHEMA (no EXCEPT): tab_pub replicated after except list cleared' +); +$result = + $node_subscriber->safe_psql('postgres', + "SELECT count(*) FROM sch1.tab_exc"); +is($result, qq(2), + 'ALTER ... SET TABLES IN SCHEMA (no EXCEPT): tab_exc replicated after except list cleared' +); + $node_subscriber->safe_psql('postgres', 'DROP SUBSCRIPTION sch_sub'); $node_publisher->safe_psql('postgres', 'DROP PUBLICATION sch_pub'); @@ -445,6 +505,36 @@ $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'); +# 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 (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.50.1 (Apple Git-155)