From 344321031a67496ffb949cc927e68d22878fdd41 Mon Sep 17 00:00:00 2001 From: Nisha Moond Date: Mon, 27 Apr 2026 13:22:38 +0530 Subject: [PATCH v4 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 (TABLE s.t1); --- doc/src/sgml/ref/alter_publication.sgml | 36 ++++-- src/backend/catalog/pg_publication.c | 2 +- src/backend/commands/publicationcmds.c | 145 ++++++++++++++++++++-- src/bin/psql/tab-complete.in.c | 28 +++++ src/test/regress/expected/publication.out | 20 +++ src/test/regress/sql/publication.sql | 10 ++ src/test/subscription/t/037_except.pl | 90 ++++++++++++++ 7 files changed, 305 insertions(+), 26 deletions(-) diff --git a/doc/src/sgml/ref/alter_publication.sgml b/doc/src/sgml/ref/alter_publication.sgml index 8aedfd951a5..1b73b34e43a 100644 --- a/doc/src/sgml/ref/alter_publication.sgml +++ b/doc/src/sgml/ref/alter_publication.sgml @@ -54,10 +54,6 @@ ALTER PUBLICATION name RENAME TO and table_object is: [ ONLY ] table_name [ * ] - -and except_table_object is: - - [ ONLY ] table_name [ * ] @@ -97,7 +93,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,7 +116,8 @@ 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 and + SET TABLES IN SCHEMA to exclude specific tables from a schema-level publication. EXCEPT is not supported with DROP TABLES IN SCHEMA; instead, dropping a schema from the publication automatically removes all of its associated @@ -217,12 +218,12 @@ 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. Table names may be - schema-qualified or unqualified; unqualified names are implicitly - qualified with the schema named in the same clause. See - for further details on the + and SET 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. Table names may be schema-qualified or unqualified; unqualified + names are implicitly qualified with the schema named in the same clause. + See for further details on the semantics of EXCEPT. @@ -328,6 +329,17 @@ ALTER PUBLICATION sales_publication ADD TABLES IN SCHEMA sales EXCEPT (TABLE sal + + 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 (TABLE 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 858c0d0944d..96f653c5e95 100644 --- a/src/backend/commands/publicationcmds.c +++ b/src/backend/commands/publicationcmds.c @@ -1590,9 +1590,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; /* @@ -1614,17 +1616,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; @@ -1656,6 +1647,95 @@ 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; + 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)); + } + + explicitrelids = GetIncludedPublicationRelations(pubid, + PUBLICATION_PART_ROOT); + + /* + * Validate that each excluded table is not also in the explicit table + * list (which would be contradictory). + */ + foreach(lc, rels) + { + PublicationRelInfo *pri = (PublicationRelInfo *) lfirst(lc); + Oid relid = RelationGetRelid(pri->relation); + Oid relns = RelationGetNamespace(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); } } @@ -2310,6 +2390,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, @@ -2326,8 +2408,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 c930c6c672e..5afcce87f72 100644 --- a/src/bin/psql/tab-complete.in.c +++ b/src/bin/psql/tab-complete.in.c @@ -2391,6 +2391,34 @@ 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", MatchAny, "EXCEPT", "(", "TABLE")) + { + if (strchr(previous_words[3], ',') == NULL) + { + set_completion_reference(previous_words[3]); + 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, ',')) + { + 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", "(", "TABLE", 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 ca163b2bb01..5b6bf3bbddd 100644 --- a/src/test/regress/expected/publication.out +++ b/src/test/regress/expected/publication.out @@ -538,6 +538,26 @@ 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 (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" + +-- error: 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 +-- 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 (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... + ^ 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 3a9a9e403f8..fdc6940c87d 100644 --- a/src/test/regress/sql/publication.sql +++ b/src/test/regress/sql/publication.sql @@ -259,6 +259,16 @@ CREATE PUBLICATION testpub_alter_except; ALTER PUBLICATION testpub_alter_except ADD TABLES IN SCHEMA pub_test EXCEPT (TABLE 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 (TABLE 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 (TABLE pub_test.testpub_tbl_s2); + +-- 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 (TABLE 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 64f0e2173b2..580acd74bca 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 (TABLE 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 (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.50.1 (Apple Git-155)