public inbox for [email protected]
help / color / mirror / Atom feedFrom: vignesh C <[email protected]>
To: shveta malik <[email protected]>
Cc: David G. Johnston <[email protected]>
Cc: Amit Kapila <[email protected]>
Cc: Peter Smith <[email protected]>
Cc: Shlok Kyal <[email protected]>
Cc: Dilip Kumar <[email protected]>
Cc: Zhijie Hou (Fujitsu) <[email protected]>
Cc: YeXiu <[email protected]>
Cc: Ian Lawrence Barwick <[email protected]>
Cc: Bharath Rupireddy <[email protected]>
Cc: PostgreSQL Hackers <[email protected]>
Subject: Re: Skipping schema changes in publication
Date: Tue, 10 Feb 2026 23:11:03 +0530
Message-ID: <CALDaNm2EU7qdEnGmirzVn9a1O-6y3q5c7nzsBZCuyVjaPS37mg@mail.gmail.com> (raw)
In-Reply-To: <CAJpy0uC_0uvhmXyWegKGRozhpyoLGHwHrUAK=Wk+bSmzqLMoSw@mail.gmail.com>
References: <CALDaNm3=JrucjhiiwsYQw5-PGtBHFONa6F7hhWCXMsGvh=tamA@mail.gmail.com>
<CALj2ACVOzhs+BD+abFV2x4oKJdsDNd6SgsE7r8UjnZDCKGEckA@mail.gmail.com>
<CAA4eK1K6Kr88d2S0zFdHRMyuoaZeNh+ktU+oigmCuD09_x_-+g@mail.gmail.com>
<CAHut+PsvC-NezO3MJkdyEz=G1QRje2LntjwhQiEeVbmhOQuBMA@mail.gmail.com>
<CALDaNm18VH2j8cTqfELHQ=0ZNognbGBhbHPteJenWQC6C2dueQ@mail.gmail.com>
<CALDaNm0k_0Ccj47wzJzzPFwgQB7w=R5+Q2_nSqYrmMmjhmcRUw@mail.gmail.com>
<CAHut+Pv_0DwyWoGQNMF+G2AGqMuJTzWQKRtmxaC+=zLTPL-Zkw@mail.gmail.com>
<CALDaNm2-GJt2HsYTkLqQ=ecm=R-vOBw1=aM_d2EiYbz39x_cTQ@mail.gmail.com>
<TYCPR01MB8373C3120C2B3112001ED6F1EDCF9@TYCPR01MB8373.jpnprd01.prod.outlook.com>
<CALDaNm0iZZDB300Dez_97S8G6_RW5QpQ8ef6X3wq8tyK-8wnXQ@mail.gmail.com>
<CAHut+PtiomM+iyAZHvb2dzfsPvRru266KuBe49hKy2n2h+m_zA@mail.gmail.com>
<CALDaNm30KDnwX4Czi29fqLb8JBkuwqjbpj9ixwNXXox574NZqQ@mail.gmail.com>
<CALDaNm1PfKRJsEzbKpyt=v4p3bw+_SzE+LFPsMhR5X+qs+0pPw@mail.gmail.com>
<TYCPR01MB83730A2F1D6A5303E9C1416AEDD99@TYCPR01MB8373.jpnprd01.prod.outlook.com>
<CALDaNm0sAU4s1KTLOEWv=rYo5dQK6uFTJn_0FKj3XG1Nv4D-qw@mail.gmail.com>
<CALDaNm3CLRa95tpas6tEj8x58MUNDShxBNoYS+P8Uq5cryoAOw@mail.gmail.com>
<CALDaNm0EKC3o=v+F7GneGibuCULGKkBWXmNaVB4GR9HoqD066A@mail.gmail.com>
<CALDaNm1Z1Rmqj9s6P9ZzmrVA9F_vZ_DwwhYAJmsjqmY6dS3-hA@mail.gmail.com>
<CAB8KJ=jJGuW=ozKmXZzKDUHZ_-J2ZYGOtJo=i2cnNbSu6=KuYg@mail.gmail.com>
<CALDaNm1mbFP8fxHU_H1Ex4cT2Aq3n8FE79tq0TO5ThvFnDUYMA@mail.gmail.com>
<CAB8KJ=jq4RwTs8K7pokmXQwQppP2ChVJLMSAdXaxAX+c1r+mdg@mail.gmail.com>
<CALDaNm1mJvLni8GODebKBmyegXuZ18bLoG-Pz6H1MCX=vphCYA@mail.gmail.com>
<CALDaNm3dWZCYDih55qTNAYsjCvYXMFv=46UsDWmfCnXMt3kPCg@mail.gmail.com>
<CALDaNm1AQZYgT0tALRrkvpP1Q+8+e7vkGCUjQ-jim1C0q3e=zA@mail.gmail.com>
<CAA4eK1KRdAPC=5=7tQ1GW0cRwD=zaDMi+T4u_k4GxPhPY6e8BQ@mail.gmail.com>
<OS3PR01MB5718C8BE84B862E7E0CEC29B94BD2@OS3PR01MB5718.jpnprd01.prod.outlook.com>
<CAA4eK1KYQz7cf46_D=6VkZ4J6Y8vJ88MMi=6zm2TJXDP+V1mLg@mail.gmail.com>
<CANhcyEXZq4mP5dNgg7u=sMPwvxA4_ZN9U92uZEuzs=0xTu+8Yg@mail.gmail.com>
<CANhcyEXspT3v5-Tdop9uqQV2HWBvZoN5P0BxXQ6Md6Mr7GXK9A@mail.gmail.com>
<CAHut+PuiaLOCkiAx9nPnjk6wTbPFvnm9T5svTuKbgwJwTdea8w@mail.gmail.com>
<CANhcyEV_MePxgftHY65et1WdOAk70M0C7PZ1STPUO8PXHVB1YA@mail.gmail.com>
<CAHut+Ps0hSNqrjv_jT1AuXxO-CrZue3ixE0jKsxVhtArMrkujQ@mail.gmail.com>
<CANhcyEXX3viVpYcGHD_fzhf_f6CDQWr2+VBywrJf5zm_XiB4tg@mail.gmail.com>
<CAHut+PsXP_61ZXuVOx5u9FZGK3oH4taaA59oOzgqyygZx8ezWw@mail.gmail.com>
<CANhcyEU+aPu6iAH2cTA0cDtn3pd6c_njBONCt3FubYZoEEnm8Q@mail.gmail.com>
<CAHut+Pv2P6dJ7hZj_fmzN+=xzjvpOpgkAJvDZg3TD2xpvmY1NQ@mail.gmail.com>
<CANhcyEW2LK4diNeCG862DE40yQoV3VAgf59kXUq2TuR8fnw5vQ@mail.gmail.com>
<CAHut+PuSHScrODVGCM7P53Mv1HE2N6ThzkH4+gQ1eFXVeD-OCA@mail.gmail.com>
<CANhcyEUtYV-9ujtxLasnxN_peT+3LuZjcRx1xUECh1CCmANB8w@mail.gmail.com>
<CAHut+PuviFA6C7qps=+kDYfe3P99as8NCjbR=SYxoi0o96ipoA@mail.gmail.com>
<CANhcyEXkeg3sjkS3DS9yU1ckz4ozUBNZ+RmrWaRNSSVCR8RquA@mail.gmail.com>
<CAHut+PsHavMy_KJ0MwR9J6q0BTTty54TxS-KBZc7X6tb4u7rfA@mail.gmail.com>
<CANhcyEU=k4+0BqOu25N76g738XKUwfLGGdf8e+ssGiRKHC4RwQ@mail.gmail.com>
<CAHut+Ptdi8txJbMc+5Sp2uB4QLGOp-rZdkBbXyhAkxim0iAhBQ@mail.gmail.com>
<CANhcyEW+uJB_bvQLEaZCgoRTc1=i+QnrPPHxZ2=0SBSCyj9pkg@mail.gmail.com>
<CAHut+Ptq0-tMYUOvG3yR34AvuEzR9vUH=muqV_=uEO3zCuA6rA@mail.gmail.com>
<CANhcyEUEMWSkTfGc7Q3B+UiOzSiOmOGLgK-+C5DXwtCGOnDBhg@mail.gmail.com>
<CAHut+PuAgSDr3kbSxPMYEyCeGiJ5hgaT1JUvuiYPRT=Q+--O-Q@mail.gmail.com>
<CANhcyEU_uuiKMRrd_E_DeYsyCvwY_u995E-Do3d66J7tQnzdzw@mail.gmail.com>
<CANhcyEWK9sj9UY4uaV36Q8qxUv=8Joch0o98RCN5U3xLjUxAag@mail.gmail.com>
<CALDaNm32XQDR4qsOhPQeophVbZ8r+ShJSSssoVfdPcwG6joPHQ@mail.gmail.com>
<CANhcyEVt2CBnG7MOktaPPV4rYapHR-VHe5=qoziTZh1L9SVc6w@mail.gmail.com>
<CAHut+PtGu2j72yV_as_TVKfWr3ctd18svReGEx3xa=q5BtKyKA@mail.gmail.com>
<CANhcyEUh9ki36VTXyYf8UqUrfLX9ZhfP_f2LjpvvycgqWLQqqQ@mail.gmail.com>
<CAHut+Pv96u0VciuSx2B99jDHEvn7svVJynCmw-qYb=z4Kc2knA@mail.gmail.com>
<CANhcyEWGiWwGt1-v6d9bCAae9Np7cNPt+iRV9PXBZ0z=75XEVw@mail.gmail.com>
<CANhcyEWAQHtUfgNPA2m-+okEh7pXaK5irBm+yzyNVJXL2LUTXw@mail.gmail.com>
<CAHut+PuzcKXheZwgNvDJkwK5txd1kzNRxCmcJcbr=_9mGHjKtA@mail.gmail.com>
<CANhcyEXCKPCAdoqBLAhxt64Nwf+7T52dd8daE3qvhBNTvro13Q@mail.gmail.com>
<CAHut+Pu50yWjMR5Mswhi6uVKQmn3hO9o0ocRAgXyUUf4cnVTwg@mail.gmail.com>
<CANhcyEV7ewT+nfLM2owquxW-_6m8Ju+P93y=acoS=JCBHoT-MQ@mail.gmail.com>
<CAJpy0uDdQH7b=LRn_HevbmFACpvq9=c32Vb3tRvjSOnDsQd74w@mail.gmail.com>
<CAA4eK1KZ1Sb0soHp3HH2htwJ3=qka-eQjW35vOW3+4VeWw4VoQ@mail.gmail.com>
<CANhcyEXwLrQsec6g+1dqWTKyJQMQMh=getj28C+zLL14BjuumA@mail.gmail.com>
<CAJpy0uD35+YmDahVNUm+NZsjisV_k2hUDBkiFOJDwAJ1o1CT9A@mail.gmail.com>
<CANhcyEV_EVi5cgJ6WPvmeVAqjCS7Of+VAWuRHZtsVf8PQb_z7g@mail.gmail.com>
<CAHut+Pu5Ukuvd_5oK_mTEjSOEAYupbEXzRqXwSPoLe_sDkA_fg@mail.gmail.com>
<CANhcyEWg2WbEW_fFwk0D3J2KBrUF7th6VrE+gvESgkUKP9VpZg@mail.gmail.com>
<CAJpy0uBegaytfG=AS5VUb-6jAEDzC374-1icn-hP5AnRoMJ+Lg@mail.gmail.com>
<CAJpy0uD+orAxUWKq9Ogf5FEWtXcwkQXb_YZOvXqDc8b15nJe9A@mail.gmail.com>
<CANhcyEVDTPVRWDm-BdJe0yaWFuz1ozTUFNHKvzX2YuiUHh=ArA@mail.gmail.com>
<CAHut+Pu6ameXD4YwbhMXf8kHBhPJXGpOmc21R5o7Lo18hkSKMQ@mail.gmail.com>
<CANhcyEUiwF9w2U6CzkuOsXkLYMOjPs=6O8QyjU_5fDfYWyMswg@mail.gmail.com>
<CAJpy0uCnK7A8HE_mjWaVHDn9Q=CNJQM_mB=QTtseh=GQ4aGumQ@mail.gmail.com>
<CANhcyEWewbuqrwLTuwZUSGRGmLSmRadiNKyDB1yt0Sm+JTMHZA@mail.gmail.com>
<CAA4eK1KFdzZD3JvjfajhbHYYU9DErNcDVQZxfvGKv9npNfO5fQ@mail.gmail.com>
<CAJpy0uD81HRrMYr7S-6AV4W2PtbGKM-nf2D89zsoMHJ9jZssUg@mail.gmail.com>
<CAFiTN-sbpXXbU=u0U7SWjjouScAddZq+xb2FP8zfyVZZZt8z7w@mail.gmail.com>
<CALDaNm2x3fR+AEji0ZruTdss-4WDatraXKs1QA44eVnsBmbUiA@mail.gmail.com>
<CALDaNm3kX=16L-72m13CqXL9uAiHURNZ+BLo-HfTEYHDFejj-A@mail.gmail.com>
<CAJpy0uBfEuzYX+qjAPM+GV5duOwMNqO6fkDtsN1OzONVNR9WGQ@mail.gmail.com>
<CALDaNm2MZU8-JbFruQAxF8OZfcH4ZsBrCsWDg3VMbO-P+xKmBg@mail.gmail.com>
<CANhcyEXP5BS+0Hq2o=Jmx6J5Nf+H0TW9yayO21NnVwtFqauDGw@mail.gmail.com>
<CALDaNm1Y5C_-gOA95a+07P5z4DY=PuXbnvdqMT6g7t7OEKD2YA@mail.gmail.com>
<CANhcyEWda-0kWVCn8zQ4z9snFK4sCo1-JEewFGWs-9PMrJAmrg@mail.gmail.com>
<CAJpy0uB=gaJgDaP8MiVeZmpxALrmDPbx=fqoidAbzhwEO3cv-g@mail.gmail.com>
<CAA4eK1J9=vfNYU=K=0pfEf=b8iZFB0FGocCOpJL1tdTY-g94uw@mail.gmail.com>
<CAHut+PsxDe-mVq_6YyhaUCyPUuohZE5dRtf80syP3y_n+Z6Tog@mail.gmail.com>
<CAA4eK1JXdUArUqJ_uPjwXcYR8qj2hJyVORdRNhmx9=Kp0EXumg@mail.gmail.com>
<CAKFQuwauV8v46mM3kZ9ducnN9Oq6E2etLbvf5KxVj4K0CEZKHg@mail.gmail.com>
<CAJpy0uC_0uvhmXyWegKGRozhpyoLGHwHrUAK=Wk+bSmzqLMoSw@mail.gmail.com>
On Tue, 10 Feb 2026 at 11:35, shveta malik <[email protected]> wrote:
>
> On Mon, Feb 9, 2026 at 11:52 AM David G. Johnston
> <[email protected]> wrote:
> >
> > On Sunday, February 8, 2026, Amit Kapila <[email protected]> wrote:
> >>
> >> On Mon, Feb 9, 2026 at 6:41 AM Peter Smith <[email protected]> wrote:
> >>
> >> > Hi Amit.
> >> >
> >> > I understand there can be some tricky scenarios where partitions are
> >> > involved, but I was not sure why "pub1: FOR ALL Tables EXCEPT (tab1)
> >> > and pub2: FOR TABLE tab1" is an example of contradictory behaviour.
> >> >
> >> > Consider if the publisher has 3 tables tab1,tab2,tab3:
> >> > Here, "pub1: FOR ALL Tables EXCEPT (tab1)" is like a shorthand for
> >> > saying "pub1: FOR TABLE tab2,tab3"
> >> > So what's wrong for the subscriber to combine pub1 and pub2 in this case?
> >> >
> >>
> >> It is because one of the publications (pub2) indicates to include a
> >> particular table tab1 and the other one (pub1) to exclude the same
> >> table. And things become much more complex when the Except list
> >> contains partitions as shown in Shveta's example. So, I think it makes
> >> sense to keep things simple at least for the first version, we can
> >> consider to uplift this restriction if we see some use cases from the
> >> field.
> >>
> >>
> >
> > I’m with Peter here - I do not think it is wise to expose the exception listing outside the publication. Publication combinations should be purely additive in much the same way grants are in the system. Except lists are internal shorthand for describing the positive list of tables a publication makes available - all tables except.
> >
>
> The earlier case -
> pub1: FOR ALL TABLES EXCEPT (tab1)
> pub2: FOR TABLE tab1 WHERE (c = 99)
>
> seems a valid scenario, and we are currently evaluating its
> implementation feasibility under Approach 1.
>
> OTOH, subscribing to two different publications that are both defined
> as 'FOR ALL TABLES' but have different EXCEPT lists introduces
> unnecessary implementation complexity without a clear business use
> case. This becomes especially complex when the publications exclude
> different partitions of the same partitioned table. For example:
>
> pub1: FOR ALL TABLES EXCEPT (part1, part2) WITH
> (publish_via_partition_root=true)
> pub2: FOR ALL TABLES EXCEPT (part7) WITH
> (publish_via_partition_root=false)
>
> IMO, there is no clear need for a user to create multiple 'ALL TABLES'
> publications with different EXCEPT lists and then combine them at the
> subscriber level. Given this, to keep the patch simpler, we plan to
> emit an error for this scenario (multi-pub EXCEPTs case) for now. If a
> valid requirement emerges in the future, we can revisit and consider
> supporting it.
I agree with this. This has been handled.
Additionally it also resolves conflicts between overlapping
publications with differing publish_via_partition_root settings. When
a partition is excluded from a "via root" publication but included in
another table publication with "via root" as false, then it is
published through its root ancestor by default to give priority to the
root table.
Ex:
-- Publisher tables
CREATE TABLE tab_root (id int, range_col int) PARTITION BY RANGE (range_col);
CREATE TABLE tab_part_1 PARTITION OF tab_root FOR VALUES FROM (1) TO
(1000) PARTITION BY RANGE (range_col);
CREATE TABLE tab_part_2 PARTITION OF tab_root FOR VALUES FROM (1000)
TO (2000) PARTITION BY RANGE (range_col);
CREATE TABLE tab_part_1_p1 PARTITION OF tab_part_1 FOR VALUES FROM (1) TO (500);
CREATE TABLE tab_part_1_p2 PARTITION OF tab_part_1 FOR VALUES FROM
(500) TO (1000);
CREATE TABLE tab_part_2_p1 PARTITION OF tab_part_2 FOR VALUES FROM
(1000) TO (1500);
CREATE TABLE tab_part_2_p2 PARTITION OF tab_part_2 FOR VALUES FROM
(1500) TO (2000);
-- Publication
CREATE PUBLICATION pub1 for ALL TABLES EXCEPT table (tab_part_1_p1,
tab_part_1_p2) WITH (PUBLISH_VIA_PARTITION_ROOT=true);
CREATE PUBLICATION pub2 for table tab_part_1_p2 WITH
(PUBLISH_VIA_PARTITION_ROOT=false);
-- Subscriber tables
CREATE TABLE tab_root (id int, range_col int);
CREATE TABLE tab_part_1 (id int, range_col int);
CREATE TABLE tab_part_2 (id int, range_col int);
CREATE TABLE tab_part_1_p1 (id int, range_col int);
CREATE TABLE tab_part_1_p2 (id int, range_col int);
CREATE TABLE tab_part_2_p1 (id int, range_col int);
CREATE TABLE tab_part_2_p2 (id int, range_col int);
Subscription with both pub1 and pub2 publications.:
create subscription sub1 connection 'dbname=postgres host=localhost
port=5432' publication pub1,pub2;
Consider the insert statements:
insert into tab_part_1_p1 values(1,100);
insert into tab_part_1_p2 values(2,600);
insert into tab_part_2_p1 values(3,1100);
insert into tab_part_2_p2 values(4,1600);
The tab_part_2_p2 table's row is replicated to the subscriber’s
tab_root table, since root table publications take precedence.
postgres=# select * from tab_root ;
id | range_col
----+-----------
2 | 600
3 | 1100
4 | 1600
(3 rows)
The attached v42 version patch has the changes for the same.
Regards,
Vignesh
Attachments:
[application/octet-stream] v42-0001-Skip-publishing-the-tables-specified-in-EXCEPT-T.patch (102.0K, 2-v42-0001-Skip-publishing-the-tables-specified-in-EXCEPT-T.patch)
download | inline diff:
From bb5bdaa4741f5742b2b2a16b5652d21584ea01c6 Mon Sep 17 00:00:00 2001
From: Vignesh C <[email protected]>
Date: Wed, 28 Jan 2026 10:47:02 +0530
Subject: [PATCH v42 1/2] Skip publishing the tables specified in EXCEPT TABLE.
A new "EXCEPT TABLE" clause for CREATE PUBLICATION allows one or more
tables to be excluded. The publisher will not send the data of excluded
tables to the subscriber.
The new syntax allows specifying excluded relations when creating a
publication. For example:
CREATE PUBLICATION pub1 FOR ALL TABLES EXCEPT TABLE (t1,t2);
A new column "prexcept" is added to table "pg_publication_rel", to flag
the relations that the user wants to exclude from the publications.
For EXCEPT TABLE on partition tables, extended fetch_remote_table_info() to
compute the effective set of relations used for the initial COPY. When
exclusions are present, the root partitioned table can no longer be used
directly; instead, derive the list of non-excluded leaf partitions and
combine them with UNION ALL. When no exclusions exist, retain the existing
behavior and copy from the root relation as before.
Also updated the behaviour of incremental sync for partition tables as
per approach 1.
Approach 1 is discussed at:
https://www.postgresql.org/message-id/CAJpy0uD81HRrMYr7S-6AV4W2PtbGKM-nf2D89zsoMHJ9jZssUg@mail.gmail.com
pg_dump is updated for the new EXCEPT TABLE syntax.
The psql \d family of commands can now display excluded tables.
Bump catalog version.
---
doc/src/sgml/catalogs.sgml | 10 +
doc/src/sgml/logical-replication.sgml | 6 +-
doc/src/sgml/ref/create_publication.sgml | 55 +-
doc/src/sgml/ref/psql-ref.sgml | 5 +-
src/backend/catalog/pg_publication.c | 597 +++++++++++++++++-
src/backend/commands/publicationcmds.c | 109 ++--
src/backend/commands/subscriptioncmds.c | 75 +++
src/backend/commands/tablecmds.c | 4 +-
src/backend/parser/gram.y | 42 +-
src/backend/replication/logical/tablesync.c | 150 ++++-
src/backend/replication/pgoutput/pgoutput.c | 204 +++++-
src/backend/utils/cache/relcache.c | 21 +-
src/bin/pg_dump/pg_dump.c | 68 ++
src/bin/pg_dump/pg_dump.h | 1 +
src/bin/pg_dump/t/002_pg_dump.pl | 30 +
src/bin/psql/describe.c | 87 ++-
src/bin/psql/tab-complete.in.c | 12 +-
src/include/catalog/pg_proc.dat | 7 +
src/include/catalog/pg_publication.h | 15 +-
src/include/catalog/pg_publication_rel.h | 1 +
src/include/nodes/parsenodes.h | 3 +
src/include/replication/worker_internal.h | 6 +
src/test/regress/expected/publication.out | 92 ++-
src/test/regress/sql/publication.sql | 36 +-
src/test/subscription/meson.build | 1 +
.../t/037_rep_changes_except_table.pl | 314 +++++++++
src/tools/pgindent/typedefs.list | 1 +
27 files changed, 1800 insertions(+), 152 deletions(-)
create mode 100644 src/test/subscription/t/037_rep_changes_except_table.pl
diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index 332193565e2..d40e5cc1090 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -6581,6 +6581,16 @@ SCRAM-SHA-256$<replaceable><iteration count></replaceable>:<replaceable>&l
if there is no publication qualifying condition.</para></entry>
</row>
+ <row>
+ <entry role="catalog_table_entry"><para role="column_definition">
+ <structfield>prexcept</structfield> <type>bool</type>
+ </para>
+ <para>
+ True if the relation is excluded from the publication. See
+ <link linkend="sql-createpublication-params-for-except-table"><literal>EXCEPT TABLE</literal></link>.
+ </para></entry>
+ </row>
+
<row>
<entry role="catalog_table_entry"><para role="column_definition">
<structfield>prattrs</structfield> <type>int2vector</type>
diff --git a/doc/src/sgml/logical-replication.sgml b/doc/src/sgml/logical-replication.sgml
index 5028fe9af09..f01281fd4f9 100644
--- a/doc/src/sgml/logical-replication.sgml
+++ b/doc/src/sgml/logical-replication.sgml
@@ -116,7 +116,11 @@
<literal>FOR TABLES IN SCHEMA</literal>, <literal>FOR ALL TABLES</literal>,
or <literal>FOR ALL SEQUENCES</literal>. Unlike tables, sequences can be
synchronized at any time. For more information, see
- <xref linkend="logical-replication-sequences"/>.
+ <xref linkend="logical-replication-sequences"/>. When a publication is
+ created with <literal>FOR ALL TABLES</literal>, tables can be explicitly
+ excluded from publication using the
+ <link linkend="sql-createpublication-params-for-except-table"><literal>EXCEPT TABLE</literal></link>
+ clause.
</para>
<para>
diff --git a/doc/src/sgml/ref/create_publication.sgml b/doc/src/sgml/ref/create_publication.sgml
index 6efbb915cec..730b9c4bced 100644
--- a/doc/src/sgml/ref/create_publication.sgml
+++ b/doc/src/sgml/ref/create_publication.sgml
@@ -32,12 +32,16 @@ CREATE PUBLICATION <replaceable class="parameter">name</replaceable>
<phrase>and <replaceable class="parameter">publication_all_object</replaceable> is one of:</phrase>
- ALL TABLES
+ ALL TABLES [ EXCEPT [ TABLE ] ( <replaceable class="parameter">except_table_object</replaceable> [, ... ] ) ]
ALL SEQUENCES
<phrase>and <replaceable class="parameter">table_and_columns</replaceable> is:</phrase>
[ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ] [ ( <replaceable class="parameter">column_name</replaceable> [, ... ] ) ] [ WHERE ( <replaceable class="parameter">expression</replaceable> ) ]
+
+<phrase>and <replaceable class="parameter">except_table_object</replaceable> is:</phrase>
+
+ [ ONLY ] <replaceable class="parameter">table_name</replaceable> [ * ]
</synopsis>
</refsynopsisdiv>
@@ -164,7 +168,8 @@ CREATE PUBLICATION <replaceable class="parameter">name</replaceable>
<listitem>
<para>
Marks the publication as one that replicates changes for all tables in
- the database, including tables created in the future.
+ the database, including tables created in the future. Tables listed in
+ EXCEPT TABLE are excluded from the publication.
</para>
</listitem>
</varlistentry>
@@ -184,6 +189,35 @@ CREATE PUBLICATION <replaceable class="parameter">name</replaceable>
</listitem>
</varlistentry>
+ <varlistentry id="sql-createpublication-params-for-except-table">
+ <term><literal>EXCEPT TABLE</literal></term>
+ <listitem>
+ <para>
+ This clause specifies a list of tables to be excluded from the
+ publication.
+ </para>
+ <para>
+ For inherited tables, if <literal>ONLY</literal> is specified before the
+ table name, only that table is excluded from the publication. If
+ <literal>ONLY</literal> is not specified, the table and all its descendant
+ tables (if any) are excluded. Optionally, <literal>*</literal> can be
+ specified after the table name to explicitly indicate that descendant
+ tables are excluded.
+ </para>
+ <para>
+ For partitioned tables, when a table is specified in EXCEPT TABLE, then
+ changes to that table and all of its partitions (that is, the entire
+ partition subtree rooted at that table) are not replicated. This behavior
+ is the same regardless of whether
+ <literal>publish_via_partition_root</literal> is set to
+ <literal>true</literal> or <literal>false</literal>. The
+ <literal>publish_via_partition_root</literal> setting only determines
+ which relation is used as the publishing relation for replicated changes,
+ and does not affect exclusion semantics.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="sql-createpublication-params-with">
<term><literal>WITH ( <replaceable class="parameter">publication_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] )</literal></term>
<listitem>
@@ -489,6 +523,23 @@ CREATE PUBLICATION all_sequences FOR ALL SEQUENCES;
all sequences for synchronization:
<programlisting>
CREATE PUBLICATION all_tables_sequences FOR ALL TABLES, ALL SEQUENCES;
+</programlisting>
+ </para>
+
+ <para>
+ Create a publication that publishes all changes in all tables except
+ <structname>users</structname> and <structname>departments</structname>:
+<programlisting>
+CREATE PUBLICATION all_tables_except FOR ALL TABLES EXCEPT (users, departments);
+</programlisting>
+ </para>
+
+ <para>
+ Create a publication that publishes all sequences for synchronization, and
+ all changes in all tables except <structname>users</structname> and
+ <structname>departments</structname>:
+<programlisting>
+CREATE PUBLICATION all_sequences_tables_except FOR ALL SEQUENCES, ALL TABLES EXCEPT (users, departments);
</programlisting>
</para>
</refsect1>
diff --git a/doc/src/sgml/ref/psql-ref.sgml b/doc/src/sgml/ref/psql-ref.sgml
index 8b1d948ba05..1045bc6a02c 100644
--- a/doc/src/sgml/ref/psql-ref.sgml
+++ b/doc/src/sgml/ref/psql-ref.sgml
@@ -2103,8 +2103,9 @@ SELECT $1 \parse stmt1
listed.
If <literal>x</literal> is appended to the command name, the results
are displayed in expanded mode.
- If <literal>+</literal> is appended to the command name, the tables and
- schemas associated with each publication are shown as well.
+ If <literal>+</literal> is appended to the command name, the tables,
+ excluded tables, and schemas associated with each publication are shown
+ as well.
</para>
</listitem>
</varlistentry>
diff --git a/src/backend/catalog/pg_publication.c b/src/backend/catalog/pg_publication.c
index 9a4791c573e..8829d194fe3 100644
--- a/src/backend/catalog/pg_publication.c
+++ b/src/backend/catalog/pg_publication.c
@@ -366,9 +366,11 @@ GetTopMostAncestorInPublication(Oid puboid, List *ancestors, int *ancestor_level
foreach(lc, ancestors)
{
Oid ancestor = lfirst_oid(lc);
- List *apubids = GetRelationPublications(ancestor);
+ List *apubids = NIL;
List *aschemaPubids = NIL;
+ GetRelationPublications(ancestor, &apubids, NULL);
+
level++;
if (list_member_oid(apubids, puboid))
@@ -482,6 +484,8 @@ publication_add_relation(Oid pubid, PublicationRelInfo *pri,
ObjectIdGetDatum(pubid);
values[Anum_pg_publication_rel_prrelid - 1] =
ObjectIdGetDatum(relid);
+ values[Anum_pg_publication_rel_prexcept - 1] =
+ BoolGetDatum(pri->except);
/* Add qualifications, if available */
if (pri->whereClause != NULL)
@@ -749,38 +753,58 @@ publication_add_schema(Oid pubid, Oid schemaid, bool if_not_exists)
return myself;
}
-/* Gets list of publication oids for a relation */
-List *
-GetRelationPublications(Oid relid)
+/*
+ * Get the list of publication oids associated with a specified relation.
+ *
+ * Parameter 'pubids' returns the Oids of the publications the relation is part
+ * of. Parameter 'except_pubids' returns the Oids of publications the relation
+ * is excluded from.
+ *
+ * This function returns true if the relation is part of any publication.
+ */
+bool
+GetRelationPublications(Oid relid, List **pubids, List **except_pubids)
{
- List *result = NIL;
CatCList *pubrellist;
- int i;
+ bool found = false;
/* Find all publications associated with the relation. */
pubrellist = SearchSysCacheList1(PUBLICATIONRELMAP,
ObjectIdGetDatum(relid));
- for (i = 0; i < pubrellist->n_members; i++)
+ for (int i = 0; i < pubrellist->n_members; i++)
{
HeapTuple tup = &pubrellist->members[i]->tuple;
- Oid pubid = ((Form_pg_publication_rel) GETSTRUCT(tup))->prpubid;
+ Form_pg_publication_rel pubrel = (Form_pg_publication_rel) GETSTRUCT(tup);
+ Oid pubid = pubrel->prpubid;
- result = lappend_oid(result, pubid);
+ if (pubrel->prexcept)
+ {
+ if (except_pubids)
+ *except_pubids = lappend_oid(*except_pubids, pubid);
+ }
+ else
+ {
+ if (pubids)
+ *pubids = lappend_oid(*pubids, pubid);
+ found = true;
+ }
}
ReleaseSysCacheList(pubrellist);
- return result;
+ return found;
}
/*
- * Gets list of relation oids for a publication.
+ * Internal function to get the list of relation Oids for a publication.
*
- * This should only be used FOR TABLE publications, the FOR ALL TABLES/SEQUENCES
- * should use GetAllPublicationRelations().
+ * If except_flag is true, returns the list of relations excluded from the
+ * publication; otherwise, returns the list of relations included in the
+ * publication.
*/
-List *
-GetPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt)
+static List *
+get_publication_relations(Oid pubid, PublicationPartOpt pub_partopt,
+ bool except_flag)
{
List *result;
Relation pubrelsrel;
@@ -805,8 +829,10 @@ GetPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt)
Form_pg_publication_rel pubrel;
pubrel = (Form_pg_publication_rel) GETSTRUCT(tup);
- result = GetPubPartitionOptionRelations(result, pub_partopt,
- pubrel->prrelid);
+
+ if (except_flag == pubrel->prexcept)
+ result = GetPubPartitionOptionRelations(result, pub_partopt,
+ pubrel->prrelid);
}
systable_endscan(scan);
@@ -819,6 +845,85 @@ GetPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt)
return result;
}
+/*
+ * Return the list of relation Oids for a publication.
+ *
+ * For a FOR TABLE publication, this returns the list of relations explicitly
+ * included in the publication.
+ *
+ * Publications declared with FOR ALL TABLES should use
+ * GetAllPublicationRelations() to obtain the complete set of tables covered by
+ * the publication.
+ */
+List *
+GetPublicationIncludedRelations(Oid pubid, PublicationPartOpt pub_partopt)
+{
+ Assert(!GetPublication(pubid)->alltables);
+
+ return get_publication_relations(pubid, pub_partopt, false);
+}
+
+/*
+ * Return the list of tables Oids excluded from a publication.
+ * This is only applicable for FOR ALL TABLES publications.
+ */
+List *
+GetAllPublicationExcludedTables(Oid pubid, PublicationPartOpt pub_partopt)
+{
+ List *relids = get_publication_relations(pubid, pub_partopt, true);
+ bool is_parent_excluded = true;
+
+ Assert(GetPublication(pubid)->alltables);
+
+ while (is_parent_excluded)
+ {
+ List *parentids = NIL;
+ List *curr = NIL;
+
+ foreach_oid(relid, relids)
+ {
+ Oid parentid;
+
+ if (!get_rel_relispartition(relid))
+ continue;
+
+ parentid = get_partition_parent(relid, false);
+
+ if (list_member_oid(relids, parentid))
+ continue;
+
+ parentids = list_append_unique_oid(parentids, parentid);
+ }
+
+ foreach_oid(parentid, parentids)
+ {
+ List *partitionids;
+ bool all_partitions_excluded = true;
+
+ partitionids = find_inheritance_children(parentid, NoLock);
+
+ foreach_oid(partitionid, partitionids)
+ {
+ if (!list_member_oid(relids, partitionid))
+ {
+ all_partitions_excluded = false;
+ break;
+ }
+ }
+
+ if (all_partitions_excluded)
+ curr = lappend_oid(curr, parentid);
+ }
+
+ if (curr == NIL)
+ is_parent_excluded = false;
+ else
+ relids = list_concat(relids, curr);
+ }
+
+ return relids;
+}
+
/*
* Gets list of publication oids for publications marked as FOR ALL TABLES.
*/
@@ -864,18 +969,30 @@ GetAllTablesPublications(void)
* partitioned tables, we must exclude partitions in favor of including the
* root partitioned tables. This is not applicable to FOR ALL SEQUENCES
* publication.
+ *
+ * For a FOR ALL TABLES publication, the returned list excludes tables mentioned
+ * in EXCEPT TABLE clause.
*/
List *
-GetAllPublicationRelations(char relkind, bool pubviaroot)
+GetAllPublicationRelations(Publication *pub, char relkind)
{
Relation classRel;
ScanKeyData key[1];
TableScanDesc scan;
HeapTuple tuple;
List *result = NIL;
+ List *exceptlist = NIL;
+ bool pubviaroot = pub->pubviaroot;
+ Oid pubid = pub->oid;
Assert(!(relkind == RELKIND_SEQUENCE && pubviaroot));
+ if (relkind == RELKIND_RELATION)
+ exceptlist = GetAllPublicationExcludedTables(pubid, pubviaroot ?
+ PUBLICATION_PART_ROOT :
+ PUBLICATION_PART_LEAF);
+
+
classRel = table_open(RelationRelationId, AccessShareLock);
ScanKeyInit(&key[0],
@@ -891,7 +1008,8 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Oid relid = relForm->oid;
if (is_publishable_class(relid, relForm) &&
- !(relForm->relispartition && pubviaroot))
+ !(relForm->relispartition && pubviaroot) &&
+ !list_member_oid(exceptlist, relid))
result = lappend_oid(result, relid);
}
@@ -912,7 +1030,8 @@ GetAllPublicationRelations(char relkind, bool pubviaroot)
Oid relid = relForm->oid;
if (is_publishable_class(relid, relForm) &&
- !relForm->relispartition)
+ !relForm->relispartition &&
+ !list_member_oid(exceptlist, relid))
result = lappend_oid(result, relid);
}
@@ -1116,6 +1235,430 @@ GetPublicationByName(const char *pubname, bool missing_ok)
return OidIsValid(oid) ? GetPublication(oid) : NULL;
}
+
+/* Helper: Resolve the immediate parent of a partition */
+static Oid
+get_parent_oid(Oid relid)
+{
+ List *ancestors = get_partition_ancestors(relid);
+
+ if (ancestors == NIL)
+ return InvalidOid;
+ return linitial_oid(ancestors);
+}
+
+/* Helper: Check syscache for prexcept flag */
+bool
+is_relid_published_as_except(Oid relid, Oid pubid)
+{
+ HeapTuple tup;
+ bool result = false;
+
+ tup = SearchSysCache2(PUBLICATIONRELMAP, ObjectIdGetDatum(relid), ObjectIdGetDatum(pubid));
+ if (HeapTupleIsValid(tup))
+ {
+ Form_pg_publication_rel prform = (Form_pg_publication_rel) GETSTRUCT(tup);
+
+ result = prform->prexcept;
+ ReleaseSysCache(tup);
+ }
+ return result;
+}
+
+/*
+ * publication_has_any_exception
+ *
+ * Returns true if the given publication OID has at least one entry in
+ * pg_publication_rel marked as an exception (prexcept = true).
+ */
+bool
+publication_has_any_exception(Oid pubid)
+{
+ Relation pubRel;
+ ScanKeyData skey;
+ SysScanDesc scan;
+ HeapTuple tup;
+ bool found = false;
+
+ /* Open pg_publication_rel with AccessShareLock */
+ pubRel = table_open(PublicationRelRelationId, AccessShareLock);
+
+ /*
+ * * Look up by publication OID. Using the index on (prpubid) is highly
+ * efficient.
+ */
+ ScanKeyInit(&skey,
+ Anum_pg_publication_rel_prpubid,
+ BTEqualStrategyNumber, F_OIDEQ,
+ ObjectIdGetDatum(pubid));
+
+ scan = systable_beginscan(pubRel,
+ PublicationRelPrrelidPrpubidIndexId,
+ true, NULL, 1, &skey);
+
+ /* We only need to find the first occurrence of prexcept = true */
+ while (HeapTupleIsValid(tup = systable_getnext(scan)))
+ {
+ Form_pg_publication_rel pubrelform = (Form_pg_publication_rel) GETSTRUCT(tup);
+
+ if (pubrelform->prexcept)
+ {
+ found = true;
+ break;
+ }
+ }
+
+ systable_endscan(scan);
+ table_close(pubRel, AccessShareLock);
+
+ return found;
+}
+
+/*
+ * is_relid_published_explicitly
+ *
+ * Checks if the given relation OID is explicitly part of the publication.
+ * This corresponds to the 'FOR TABLE' syntax.
+ */
+static bool
+is_relid_published_explicitly(Oid relid, Oid pubid)
+{
+ /*
+ * Search the syscache for pg_publication_rel using the (relid, pubid)
+ * index.
+ */
+ return SearchSysCacheExists2(PUBLICATIONRELMAP,
+ ObjectIdGetDatum(relid),
+ ObjectIdGetDatum(pubid));
+}
+
+/*
+ * is_schema_published
+ *
+ * Checks if the given namespace OID is part of the publication's
+ * schema list. This corresponds to the 'FOR TABLES IN SCHEMA' syntax.
+ */
+static bool
+is_schema_published(Oid nspid, Oid pubid)
+{
+ /*
+ * Search the syscache for pg_publication_namespace using the (nspid,
+ * pubid) index.
+ */
+ return SearchSysCacheExists2(PUBLICATIONNAMESPACEMAP,
+ ObjectIdGetDatum(nspid),
+ ObjectIdGetDatum(pubid));
+}
+
+/*
+ * pg_get_publication_effective_tables
+ *
+ * Given a root relation and a list of publications, calculate the set of
+ * relations that are effectively published. This is necessary for
+ * "FOR ALL TABLES" publications that use "EXCEPT TABLE" filters.
+ *
+ * The function resolves the smallest possible set of ancestors that cover
+ * all non-excluded leaf partitions.
+ */
+Datum
+pg_get_publication_effective_tables(PG_FUNCTION_ARGS)
+{
+ FuncCallContext *funcctx;
+ List *results;
+
+ if (SRF_IS_FIRSTCALL())
+ {
+ Oid root_relid = PG_GETARG_OID(0);
+ ArrayType *pub_names_array = PG_GETARG_ARRAYTYPE_P(1);
+ MemoryContext oldcontext;
+ List *pub_oids = NIL;
+ Datum *pub_datums;
+ bool *pub_nulls;
+ int pub_count;
+ TupleDesc tupdesc;
+ List *final_output = NIL;
+ bool has_clean_all_tables_pub = false;
+ bool has_any_exclusion = false;
+ ListCell *lc;
+
+ funcctx = SRF_FIRSTCALL_INIT();
+
+ deconstruct_array(pub_names_array, TEXTOID, -1, false, 'i',
+ &pub_datums, &pub_nulls, &pub_count);
+ for (int i = 0; i < pub_count; i++)
+ {
+ if (!pub_nulls[i])
+ {
+ char *pubname = TextDatumGetCString(pub_datums[i]);
+
+ pub_oids = lappend_oid(pub_oids, get_publication_oid(pubname, false));
+ }
+ }
+
+ /*
+ * Determine if we can avoid the expensive expansion. If any
+ * publication is 'FOR ALL TABLES' with no exceptions, or if
+ * absolutely no exceptions exist across all pubs, the root is the
+ * result.
+ */
+ foreach(lc, pub_oids)
+ {
+ Oid puboid = lfirst_oid(lc);
+ HeapTuple pubTup;
+ Form_pg_publication pubform;
+
+ pubTup = SearchSysCache1(PUBLICATIONOID, ObjectIdGetDatum(puboid));
+ if (!HeapTupleIsValid(pubTup))
+ continue;
+
+ pubform = (Form_pg_publication) GETSTRUCT(pubTup);
+
+ /* Check for any exclusion entries in this publication */
+ if (publication_has_any_exception(puboid))
+ has_any_exclusion = true;
+ else if (pubform->puballtables)
+ {
+ /*
+ * Found a publication that includes everything without
+ * exception
+ */
+ has_clean_all_tables_pub = true;
+ ReleaseSysCache(pubTup);
+ break;
+ }
+ ReleaseSysCache(pubTup);
+ }
+
+ /* Return root immediately if no filtering logic is needed */
+ if (has_clean_all_tables_pub || !has_any_exclusion)
+ {
+ oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+ final_output = list_make1_oid(root_relid);
+ MemoryContextSwitchTo(oldcontext);
+ }
+ else
+ {
+ List *all_tables;
+ List *all_leaves = NIL;
+ List *excluded_leaves = NIL;
+ List *allowed_leaves = NIL;
+ List *candidate_list = NIL;
+
+ /* Get all the leaf relations */
+ all_leaves = GetPubPartitionOptionRelations(all_leaves,
+ PUBLICATION_PART_LEAF,
+ root_relid);
+
+ /*
+ * Identify common excluded relations (Inheritance-aware). A table
+ * is excluded only if every publication excludes it, either
+ * explicitly or by excluding one of its ancestors.
+ */
+ foreach(lc, all_leaves)
+ {
+ Oid curr_relid = lfirst_oid(lc);
+ ListCell *lp;
+ bool is_covered_by_any_pub = false;
+
+ /* Get the inheritance chain for the current table once */
+ List *ancestors = get_partition_ancestors(curr_relid);
+
+ foreach(lp, pub_oids)
+ {
+ Oid pubid = lfirst_oid(lp);
+ HeapTuple pubTup;
+ Form_pg_publication pubform;
+ bool is_all_tables;
+
+ pubTup = SearchSysCache1(PUBLICATIONOID, ObjectIdGetDatum(pubid));
+ if (!HeapTupleIsValid(pubTup))
+ continue;
+
+ pubform = (Form_pg_publication) GETSTRUCT(pubTup);
+ is_all_tables = pubform->puballtables;
+ ReleaseSysCache(pubTup);
+
+ if (is_all_tables)
+ {
+ /* FOR ALL TABLES: Check that it isn't excluded */
+ bool is_excluded = false;
+
+ if (is_relid_published_as_except(curr_relid, pubid))
+ is_excluded = true;
+ else
+ {
+ ListCell *la;
+
+ foreach(la, ancestors)
+ {
+ if (is_relid_published_as_except(lfirst_oid(la), pubid))
+ {
+ is_excluded = true;
+ break;
+ }
+ }
+ }
+
+ if (!is_excluded)
+ {
+ is_covered_by_any_pub = true;
+ break;
+ }
+ }
+ else
+ {
+ /*
+ * Check if the table itself or its schema is in the
+ * pub.
+ */
+ if (is_relid_published_explicitly(curr_relid, pubid) ||
+ is_schema_published(get_rel_namespace(curr_relid), pubid))
+ {
+ is_covered_by_any_pub = true;
+ }
+ else
+ {
+ /*
+ * Check if any ancestor or an ancestor's schema
+ * is in the pub.
+ */
+ ListCell *la;
+
+ foreach(la, ancestors)
+ {
+ Oid anc_oid = lfirst_oid(la);
+
+ if (is_relid_published_explicitly(anc_oid, pubid) ||
+ is_schema_published(get_rel_namespace(anc_oid), pubid))
+ {
+ is_covered_by_any_pub = true;
+ break;
+ }
+ }
+ }
+ }
+
+ /*
+ * If any publication covers this table, we can stop
+ * checking others
+ */
+ if (is_covered_by_any_pub)
+ break;
+ }
+
+ /*
+ * Instead of identifying 'excluded', we identify 'allowed'.
+ * If no publication covers this table, it's effectively
+ * excluded.
+ */
+ if (!is_covered_by_any_pub)
+ excluded_leaves = lappend_oid(excluded_leaves, curr_relid);
+
+ list_free(ancestors);
+ }
+
+ /* Calculate allowed leaves (Total - Excluded) */
+ foreach(lc, all_leaves)
+ {
+ Oid curr_relid = lfirst_oid(lc);
+
+ if (!list_member_oid(excluded_leaves, curr_relid))
+ allowed_leaves = lappend_oid(allowed_leaves, curr_relid);
+ }
+
+ /* Bottom-Up Collapse. Check if parents can represent children */
+ all_tables = find_all_inheritors(root_relid, AccessShareLock, NULL);
+ candidate_list = list_copy(allowed_leaves);
+ foreach(lc, all_tables)
+ {
+ Oid curr_relid = lfirst_oid(lc);
+
+ if (get_rel_relkind(curr_relid) == RELKIND_PARTITIONED_TABLE)
+ {
+ List *branch_tree = find_all_inheritors(curr_relid, AccessShareLock, NULL);
+ List *branch_leaves = NIL;
+ ListCell *lcb;
+ bool all_allowed = true;
+
+ foreach(lcb, branch_tree)
+ {
+ Oid b_oid = lfirst_oid(lcb);
+
+ if (get_rel_relkind(b_oid) != RELKIND_PARTITIONED_TABLE)
+ branch_leaves = lappend_oid(branch_leaves, b_oid);
+ }
+
+ if (list_length(branch_leaves) == 0)
+ all_allowed = false;
+ else
+ {
+ foreach(lcb, branch_leaves)
+ {
+ if (!list_member_oid(allowed_leaves, lfirst_oid(lcb)))
+ {
+ all_allowed = false;
+ break;
+ }
+ }
+ }
+
+ if (all_allowed)
+ candidate_list = list_append_unique_oid(candidate_list, curr_relid);
+ }
+ }
+
+ /*
+ * Deduplicate: Only keep the highest level representative of any
+ * branch
+ */
+ oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+ foreach(lc, candidate_list)
+ {
+ Oid curr_relid = lfirst_oid(lc);
+ Oid parent_oid = get_parent_oid(curr_relid);
+
+ if (parent_oid == InvalidOid || !list_member_oid(candidate_list, parent_oid))
+ final_output = lappend_oid(final_output, curr_relid);
+ }
+
+ MemoryContextSwitchTo(oldcontext);
+ }
+
+ oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
+ /* Construct a tuple descriptor for the result rows. */
+ tupdesc = CreateTemplateTupleDesc(2);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 1, "schemaname",
+ TEXTOID, -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 2, "relname",
+ TEXTOID, -1, 0);
+
+ funcctx->tuple_desc = BlessTupleDesc(tupdesc);
+ funcctx->user_fctx = final_output;
+
+ MemoryContextSwitchTo(oldcontext);
+ }
+
+ /* SRF Per-call Resume */
+ funcctx = SRF_PERCALL_SETUP();
+ results = (List *) funcctx->user_fctx;
+
+ if (funcctx->call_cntr < list_length(results))
+ {
+ Oid current_relid = list_nth_oid(results, (int) funcctx->call_cntr);
+ HeapTuple rettuple;
+ Datum values[2];
+ bool nulls[2] = {false, false};
+
+ values[0] = CStringGetTextDatum(get_namespace_name(get_rel_namespace(current_relid)));
+ values[1] = CStringGetTextDatum(get_rel_name(current_relid));
+
+ rettuple = heap_form_tuple(funcctx->tuple_desc, values, nulls);
+ SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(rettuple));
+ }
+
+ SRF_RETURN_DONE(funcctx);
+}
+
/*
* Get information of the tables in the given publication array.
*
@@ -1168,17 +1711,17 @@ pg_get_publication_tables(PG_FUNCTION_ARGS)
* those. Otherwise, get the partitioned table itself.
*/
if (pub_elem->alltables)
- pub_elem_tables = GetAllPublicationRelations(RELKIND_RELATION,
- pub_elem->pubviaroot);
+ pub_elem_tables = GetAllPublicationRelations(pub_elem,
+ RELKIND_RELATION);
else
{
List *relids,
*schemarelids;
- relids = GetPublicationRelations(pub_elem->oid,
- pub_elem->pubviaroot ?
- PUBLICATION_PART_ROOT :
- PUBLICATION_PART_LEAF);
+ relids = GetPublicationIncludedRelations(pub_elem->oid,
+ pub_elem->pubviaroot ?
+ PUBLICATION_PART_ROOT :
+ PUBLICATION_PART_LEAF);
schemarelids = GetAllSchemaPublicationRelations(pub_elem->oid,
pub_elem->pubviaroot ?
PUBLICATION_PART_ROOT :
@@ -1367,7 +1910,7 @@ pg_get_publication_sequences(PG_FUNCTION_ARGS)
publication = GetPublicationByName(pubname, false);
if (publication->allsequences)
- sequences = GetAllPublicationRelations(RELKIND_SEQUENCE, false);
+ sequences = GetAllPublicationRelations(publication, RELKIND_SEQUENCE);
funcctx->user_fctx = sequences;
diff --git a/src/backend/commands/publicationcmds.c b/src/backend/commands/publicationcmds.c
index fc3a4c19e65..6bb816b219c 100644
--- a/src/backend/commands/publicationcmds.c
+++ b/src/backend/commands/publicationcmds.c
@@ -198,7 +198,12 @@ ObjectsInPublicationToOids(List *pubobjspec_list, ParseState *pstate,
switch (pubobj->pubobjtype)
{
+ case PUBLICATIONOBJ_EXCEPT_TABLE:
+ pubobj->pubtable->except = true;
+ *rels = lappend(*rels, pubobj->pubtable);
+ break;
case PUBLICATIONOBJ_TABLE:
+ pubobj->pubtable->except = false;
*rels = lappend(*rels, pubobj->pubtable);
break;
case PUBLICATIONOBJ_TABLES_IN_SCHEMA:
@@ -519,8 +524,8 @@ InvalidatePubRelSyncCache(Oid pubid, bool puballtables)
* a target. However, WAL records for TRUNCATE specify both a root and
* its leaves.
*/
- relids = GetPublicationRelations(pubid,
- PUBLICATION_PART_ALL);
+ relids = GetPublicationIncludedRelations(pubid,
+ PUBLICATION_PART_ALL);
schemarelids = GetAllSchemaPublicationRelations(pubid,
PUBLICATION_PART_ALL);
@@ -929,55 +934,61 @@ CreatePublication(ParseState *pstate, CreatePublicationStmt *stmt)
CommandCounterIncrement();
/* Associate objects with the publication. */
- if (stmt->for_all_tables)
+ ObjectsInPublicationToOids(stmt->pubobjects, pstate, &relations,
+ &schemaidlist);
+
+ /* FOR TABLES IN SCHEMA requires superuser */
+ if (schemaidlist != NIL && !superuser())
+ ereport(ERROR,
+ errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+ errmsg("must be superuser to create FOR TABLES IN SCHEMA publication"));
+
+ /* Add relations (tables) to the publication. */
+ if (relations != NIL)
{
/*
- * Invalidate relcache so that publication info is rebuilt. Sequences
- * publication doesn't require invalidation, as replica identity
- * checks don't apply to them.
+ * The 'relations' list can be non-empty in only two cases:
+ *
+ * 1. CREATE PUBLICATION ... FOR TABLE In this case, 'relations'
+ * contains the list of specified tables.
+ *
+ * 2. CREATE PUBLICATION ... FOR ALL TABLES In this case, 'relations'
+ * contains the list of tables specified in the EXCEPT TABLE clause.
+ * During parsing, the 'except' flag is set for the associated
+ * PublicationRelInfo objects.
*/
- CacheInvalidateRelcacheAll();
- }
- else if (!stmt->for_all_sequences)
- {
- ObjectsInPublicationToOids(stmt->pubobjects, pstate, &relations,
- &schemaidlist);
+ List *rels;
- /* FOR TABLES IN SCHEMA requires superuser */
- if (schemaidlist != NIL && !superuser())
- ereport(ERROR,
- errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
- errmsg("must be superuser to create FOR TABLES IN SCHEMA publication"));
-
- if (relations != NIL)
- {
- List *rels;
+ rels = OpenTableList(relations);
+ TransformPubWhereClauses(rels, pstate->p_sourcetext,
+ publish_via_partition_root);
- rels = OpenTableList(relations);
- TransformPubWhereClauses(rels, pstate->p_sourcetext,
- publish_via_partition_root);
+ CheckPubRelationColumnList(stmt->pubname, rels,
+ schemaidlist != NIL,
+ publish_via_partition_root);
- CheckPubRelationColumnList(stmt->pubname, rels,
- schemaidlist != NIL,
- publish_via_partition_root);
-
- PublicationAddTables(puboid, rels, true, NULL);
- CloseTableList(rels);
- }
+ PublicationAddTables(puboid, rels, true, NULL);
+ CloseTableList(rels);
+ }
- if (schemaidlist != NIL)
- {
- /*
- * Schema lock is held until the publication is created to prevent
- * concurrent schema deletion.
- */
- LockSchemaList(schemaidlist);
- PublicationAddSchemas(puboid, schemaidlist, true, NULL);
- }
+ if (schemaidlist != NIL)
+ {
+ /*
+ * Schema lock is held until the publication is created to prevent
+ * concurrent schema deletion.
+ */
+ LockSchemaList(schemaidlist);
+ PublicationAddSchemas(puboid, schemaidlist, true, NULL);
}
table_close(rel, RowExclusiveLock);
+ if (stmt->for_all_tables)
+ {
+ /* Invalidate relcache so that publication info is rebuilt. */
+ CacheInvalidateRelcacheAll();
+ }
+
InvokeObjectPostCreateHook(PublicationRelationId, puboid, 0);
/*
@@ -1050,8 +1061,8 @@ AlterPublicationOptions(ParseState *pstate, AlterPublicationStmt *stmt,
LockDatabaseObject(PublicationRelationId, pubform->oid, 0,
AccessShareLock);
- root_relids = GetPublicationRelations(pubform->oid,
- PUBLICATION_PART_ROOT);
+ root_relids = GetPublicationIncludedRelations(pubform->oid,
+ PUBLICATION_PART_ROOT);
foreach(lc, root_relids)
{
@@ -1170,8 +1181,8 @@ AlterPublicationOptions(ParseState *pstate, AlterPublicationStmt *stmt,
* trees, not just those explicitly mentioned in the publication.
*/
if (root_relids == NIL)
- relids = GetPublicationRelations(pubform->oid,
- PUBLICATION_PART_ALL);
+ relids = GetPublicationIncludedRelations(pubform->oid,
+ PUBLICATION_PART_ALL);
else
{
/*
@@ -1256,8 +1267,8 @@ AlterPublicationTables(AlterPublicationStmt *stmt, HeapTuple tup,
PublicationDropTables(pubid, rels, false);
else /* AP_SetObjects */
{
- List *oldrelids = GetPublicationRelations(pubid,
- PUBLICATION_PART_ROOT);
+ List *oldrelids = GetPublicationIncludedRelations(pubid,
+ PUBLICATION_PART_ROOT);
List *delrels = NIL;
ListCell *oldlc;
@@ -1358,6 +1369,7 @@ AlterPublicationTables(AlterPublicationStmt *stmt, HeapTuple tup,
oldrel = palloc_object(PublicationRelInfo);
oldrel->whereClause = NULL;
oldrel->columns = NIL;
+ oldrel->except = false;
oldrel->relation = table_open(oldrelid,
ShareUpdateExclusiveLock);
delrels = lappend(delrels, oldrel);
@@ -1408,7 +1420,8 @@ AlterPublicationSchemas(AlterPublicationStmt *stmt,
ListCell *lc;
List *reloids;
- reloids = GetPublicationRelations(pubform->oid, PUBLICATION_PART_ROOT);
+ reloids = GetPublicationIncludedRelations(pubform->oid,
+ PUBLICATION_PART_ROOT);
foreach(lc, reloids)
{
@@ -1771,6 +1784,7 @@ OpenTableList(List *tables)
pub_rel->relation = rel;
pub_rel->whereClause = t->whereClause;
pub_rel->columns = t->columns;
+ pub_rel->except = t->except;
rels = lappend(rels, pub_rel);
relids = lappend_oid(relids, myrelid);
@@ -1843,6 +1857,7 @@ OpenTableList(List *tables)
/* child inherits column list from parent */
pub_rel->columns = t->columns;
+ pub_rel->except = t->except;
rels = lappend(rels, pub_rel);
relids = lappend_oid(relids, childrelid);
diff --git a/src/backend/commands/subscriptioncmds.c b/src/backend/commands/subscriptioncmds.c
index 0b3c8499b49..a9c777b3780 100644
--- a/src/backend/commands/subscriptioncmds.c
+++ b/src/backend/commands/subscriptioncmds.c
@@ -549,6 +549,76 @@ check_publications(WalReceiverConn *wrconn, List *publications)
}
}
+/*
+ * Throws an ERROR if multiple publications with exceptions are found.
+ *
+ * This check is mandatory because logical replication currently does not
+ * support subscribing to multiple publications if more than one of them
+ * uses an exclusion list.
+ */
+static void
+check_publications_except_list(WalReceiverConn *wrconn, List *publications)
+{
+ WalRcvExecResult *res;
+ StringInfoData cmd;
+ TupleTableSlot *slot;
+ Oid tableRow[1] = {TEXTOID};
+ List *except_publications = NIL;
+
+ initStringInfo(&cmd);
+ appendStringInfoString(&cmd,
+ "SELECT DISTINCT p.pubname\n"
+ " FROM pg_catalog.pg_publication p\n"
+ " WHERE p.pubname IN (");
+
+ GetPublicationsStr(publications, &cmd, true);
+
+ appendStringInfoString(&cmd,
+ ")\n"
+ " AND EXISTS (SELECT 1\n"
+ " FROM pg_catalog.pg_publication_rel pr\n"
+ " WHERE pr.prpubid = p.oid\n"
+ " AND pr.prexcept IS TRUE)");
+
+ res = walrcv_exec(wrconn, cmd.data, 1, tableRow);
+ pfree(cmd.data);
+
+ if (res->status != WALRCV_OK_TUPLES)
+ ereport(ERROR,
+ errmsg("could not receive list of publications from the publisher: %s",
+ res->err));
+
+ slot = MakeSingleTupleTableSlot(res->tupledesc, &TTSOpsMinimalTuple);
+ while (tuplestore_gettupleslot(res->tuplestore, true, false, slot))
+ {
+ char *pubname;
+ bool isnull;
+
+ pubname = TextDatumGetCString(slot_getattr(slot, 1, &isnull));
+ Assert(!isnull);
+
+ except_publications = lappend(except_publications, makeString(pubname));
+ ExecClearTuple(slot);
+ }
+
+ ExecDropSingleTupleTableSlot(slot);
+
+ walrcv_clear_result(res);
+
+ if (list_length(except_publications) > 1)
+ {
+ StringInfoData pubnames;
+
+ initStringInfo(&pubnames);
+ GetPublicationsStr(except_publications, &pubnames, false);
+
+ ereport(ERROR,
+ errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
+ errmsg("publications %s are defined with EXCEPT TABLE", pubnames.data),
+ errhint("Subscription cannot be created using multiple publications that specify EXCEPT TABLE."));
+ }
+}
+
/*
* Auxiliary function to build a text array out of a list of String nodes.
*/
@@ -795,6 +865,7 @@ CreateSubscription(ParseState *pstate, CreateSubscriptionStmt *stmt,
char relation_state;
check_publications(wrconn, publications);
+ check_publications_except_list(wrconn, publications);
check_publications_origin_tables(wrconn, publications,
opts.copy_data,
opts.retaindeadtuples, opts.origin,
@@ -959,6 +1030,8 @@ AlterSubscription_refresh(Subscription *sub, bool copy_data,
if (validate_publications)
check_publications(wrconn, validate_publications);
+ check_publications_except_list(wrconn, sub->publications);
+
/* Get the relation list from publisher. */
pubrels = fetch_relation_list(wrconn, sub->publications);
@@ -2940,6 +3013,8 @@ fetch_relation_list(WalReceiverConn *wrconn, List *publications)
pub_names.data);
}
+ elog(LOG, "fetch_relation_list: executing query to fetch effectiverelations: \n%s",
+ cmd.data);
pfree(pub_names.data);
res = walrcv_exec(wrconn, cmd.data, column_count, tableRow);
diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c
index f976c0e5c7e..a5351fc59c6 100644
--- a/src/backend/commands/tablecmds.c
+++ b/src/backend/commands/tablecmds.c
@@ -8688,7 +8688,7 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName,
* expressions.
*/
if (attgenerated == ATTRIBUTE_GENERATED_VIRTUAL &&
- GetRelationPublications(RelationGetRelid(rel)) != NIL)
+ GetRelationPublications(RelationGetRelid(rel), NULL, NULL))
ereport(ERROR,
(errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
errmsg("ALTER TABLE / SET EXPRESSION is not supported for virtual generated columns in tables that are part of a publication"),
@@ -18884,7 +18884,7 @@ ATPrepChangePersistence(AlteredTableInfo *tab, Relation rel, bool toLogged)
* UNLOGGED, as UNLOGGED tables can't be published.
*/
if (!toLogged &&
- GetRelationPublications(RelationGetRelid(rel)) != NIL)
+ GetRelationPublications(RelationGetRelid(rel), NULL, NULL))
ereport(ERROR,
(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("cannot change table \"%s\" to unlogged because it is part of a publication",
diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y
index 713ee5c10a2..d3c3e9f59c6 100644
--- a/src/backend/parser/gram.y
+++ b/src/backend/parser/gram.y
@@ -203,6 +203,7 @@ static void processCASbits(int cas_bits, int location, const char *constrType,
static PartitionStrategy parsePartitionStrategy(char *strategy, int location,
core_yyscan_t yyscanner);
static void preprocess_pub_all_objtype_list(List *all_objects_list,
+ List **pubobjects,
bool *all_tables,
bool *all_sequences,
core_yyscan_t yyscanner);
@@ -455,6 +456,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
TriggerTransitions TriggerReferencing
vacuum_relation_list opt_vacuum_relation_list
drop_option_list pub_obj_list pub_all_obj_type_list
+ pub_except_obj_list opt_pub_except_clause
%type <retclause> returning_clause
%type <node> returning_option
@@ -592,6 +594,7 @@ static Node *makeRecursiveViewSelect(char *relname, List *aliases, Node *query);
%type <node> var_value zone_value
%type <rolespec> auth_ident RoleSpec opt_granted_by
%type <publicationobjectspec> PublicationObjSpec
+%type <publicationobjectspec> PublicationExceptObjSpec
%type <publicationallobjectspec> PublicationAllObjSpec
%type <keyword> unreserved_keyword type_func_name_keyword
@@ -10792,7 +10795,7 @@ AlterOwnerStmt: ALTER AGGREGATE aggregate_with_argtypes OWNER TO RoleSpec
*
* pub_all_obj_type is one of:
*
- * TABLES
+ * TABLES [EXCEPT [TABLE] ( table [, ...] )]
* SEQUENCES
*
* CREATE PUBLICATION FOR pub_obj [, ...] [WITH options]
@@ -10818,7 +10821,8 @@ CreatePublicationStmt:
CreatePublicationStmt *n = makeNode(CreatePublicationStmt);
n->pubname = $3;
- preprocess_pub_all_objtype_list($5, &n->for_all_tables,
+ preprocess_pub_all_objtype_list($5, &n->pubobjects,
+ &n->for_all_tables,
&n->for_all_sequences,
yyscanner);
n->options = $6;
@@ -10858,6 +10862,7 @@ PublicationObjSpec:
$$->pubtable->relation = $2;
$$->pubtable->columns = $3;
$$->pubtable->whereClause = $4;
+ $$->location = @1;
}
| TABLES IN_P SCHEMA ColId
{
@@ -10933,11 +10938,19 @@ pub_obj_list: PublicationObjSpec
{ $$ = lappend($1, $3); }
;
+opt_pub_except_clause:
+ EXCEPT opt_table '(' pub_except_obj_list ')' { $$ = $4; }
+ | /*EMPTY*/ { $$ = NIL; }
+ ;
+
PublicationAllObjSpec:
- ALL TABLES
+ ALL TABLES opt_pub_except_clause
{
$$ = makeNode(PublicationAllObjSpec);
$$->pubobjtype = PUBLICATION_ALL_TABLES;
+ $$->except_tables = $3;
+ if($$->except_tables != NULL)
+ preprocess_pubobj_list($$->except_tables, yyscanner);
$$->location = @1;
}
| ALL SEQUENCES
@@ -10954,6 +10967,23 @@ pub_all_obj_type_list: PublicationAllObjSpec
{ $$ = lappend($1, $3); }
;
+PublicationExceptObjSpec:
+ relation_expr
+ {
+ $$ = makeNode(PublicationObjSpec);
+ $$->pubobjtype = PUBLICATIONOBJ_EXCEPT_TABLE;
+ $$->pubtable = makeNode(PublicationTable);
+ $$->pubtable->except = true;
+ $$->pubtable->relation = $1;
+ $$->location = @1;
+ }
+ ;
+
+pub_except_obj_list: PublicationExceptObjSpec
+ { $$ = list_make1($1); }
+ | pub_except_obj_list ',' PublicationExceptObjSpec
+ { $$ = lappend($1, $3); }
+ ;
/*****************************************************************************
*
@@ -19794,8 +19824,9 @@ parsePartitionStrategy(char *strategy, int location, core_yyscan_t yyscanner)
* Also, checks if the pub_object_type has been specified more than once.
*/
static void
-preprocess_pub_all_objtype_list(List *all_objects_list, bool *all_tables,
- bool *all_sequences, core_yyscan_t yyscanner)
+preprocess_pub_all_objtype_list(List *all_objects_list, List **pubobjects,
+ bool *all_tables, bool *all_sequences,
+ core_yyscan_t yyscanner)
{
if (!all_objects_list)
return;
@@ -19815,6 +19846,7 @@ preprocess_pub_all_objtype_list(List *all_objects_list, bool *all_tables,
parser_errposition(obj->location));
*all_tables = true;
+ *pubobjects = list_concat(*pubobjects, obj->except_tables);
}
else if (obj->pubobjtype == PUBLICATION_ALL_SEQUENCES)
{
diff --git a/src/backend/replication/logical/tablesync.c b/src/backend/replication/logical/tablesync.c
index 19a3c21a863..c41fe41ea7a 100644
--- a/src/backend/replication/logical/tablesync.c
+++ b/src/backend/replication/logical/tablesync.c
@@ -716,21 +716,28 @@ copy_read_data(void *outbuf, int minread, int maxread)
* message provides during replication.
*
* This function also returns (a) the relation qualifications to be used in
- * the COPY command, and (b) whether the remote relation has published any
- * generated column.
+ * the COPY command, (b) whether the remote relation has published any
+ * generated column, and (c) computes the effective set of relations to be used
+ * as COPY sources when exclusions are present. When no exclusions exist, the
+ * list remains empty and the root relation is used as-is. When exclusions
+ * exist, the list contains leaf relations that are not excluded and must be
+ * combined using UNION ALL.
*/
static void
fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
- List **qual, bool *gencol_published)
+ List **qual, bool *gencol_published,
+ List **effective_relations)
{
WalRcvExecResult *res;
StringInfoData cmd;
TupleTableSlot *slot;
- Oid tableRow[] = {OIDOID, CHAROID, CHAROID};
+ Oid tableRow[] = {OIDOID, CHAROID, CHAROID, BOOLOID};
Oid attrRow[] = {INT2OID, TEXTOID, OIDOID, BOOLOID, BOOLOID};
Oid qualRow[] = {TEXTOID};
+ Oid filtertableRow[] = {TEXTOID, TEXTOID};
bool isnull;
int natt;
+ bool is_partition;
StringInfo pub_names = NULL;
Bitmapset *included_cols = NULL;
int server_version = walrcv_server_version(LogRepWorkerWalRcvConn);
@@ -740,7 +747,7 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
/* First fetch Oid and replica identity. */
initStringInfo(&cmd);
- appendStringInfo(&cmd, "SELECT c.oid, c.relreplident, c.relkind"
+ appendStringInfo(&cmd, "SELECT c.oid, c.relreplident, c.relkind, c.relispartition"
" FROM pg_catalog.pg_class c"
" INNER JOIN pg_catalog.pg_namespace n"
" ON (c.relnamespace = n.oid)"
@@ -770,6 +777,8 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
Assert(!isnull);
lrel->relkind = DatumGetChar(slot_getattr(slot, 3, &isnull));
Assert(!isnull);
+ is_partition = DatumGetBool(slot_getattr(slot, 4, &isnull));
+ Assert(!isnull);
ExecDropSingleTupleTableSlot(slot);
walrcv_clear_result(res);
@@ -954,6 +963,75 @@ fetch_remote_table_info(char *nspname, char *relname, LogicalRepRelation *lrel,
walrcv_clear_result(res);
+ if (server_version >= 190000 && !is_partition &&
+ lrel->relkind == RELKIND_PARTITIONED_TABLE)
+ {
+ resetStringInfo(&cmd);
+
+ /*
+ * This query recursively traverses the inheritance (partition) tree
+ * starting from the given table OID and determines which leaf
+ * relations should be included for replication. Exclusion propagates
+ * from parent to child, and a relation is also treated as excluded if
+ * it is explicitly marked with prexcept = true in pg_publication_rel
+ * for the specified publications. The final result returns only
+ * non excluded leaf relations.
+ */
+ appendStringInfo(&cmd, "SELECT schemaname, relname FROM pg_get_publication_effective_tables(%u, ARRAY[%s]);",
+ lrel->remoteid,
+ pub_names->data);
+
+ elog(LOG, "Executing query to get the tables:\n%s", cmd.data);
+ res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data,
+ lengthof(filtertableRow), filtertableRow);
+
+ if (res->status != WALRCV_OK_TUPLES)
+ ereport(ERROR,
+ errcode(ERRCODE_CONNECTION_FAILURE),
+ errmsg("could not get non excluded table list for table \"%s.%s\" from publisher: %s",
+ nspname, relname, res->err));
+
+ /*
+ * Store the tables as a list of schemaname and tablename.
+ */
+ slot = MakeSingleTupleTableSlot(res->tupledesc, &TTSOpsMinimalTuple);
+ while (tuplestore_gettupleslot(res->tuplestore, true, false, slot))
+ {
+ QualifiedRelationName *relinfo = palloc_object(QualifiedRelationName);
+
+ relinfo->schemaname = TextDatumGetCString(slot_getattr(slot, 1, &isnull));
+ Assert(!isnull);
+ relinfo->relname = TextDatumGetCString(slot_getattr(slot, 2, &isnull));
+ Assert(!isnull);
+
+ *effective_relations = lappend(*effective_relations, relinfo);
+
+ ExecClearTuple(slot);
+ }
+
+ ExecDropSingleTupleTableSlot(slot);
+
+ /*
+ * If there is exactly one item in the effective_relations list and it
+ * equals the table being processed, that means no actual exclusion
+ * occurred.
+ */
+ if (list_length(*effective_relations) == 1)
+ {
+ QualifiedRelationName *relinfo;
+
+ relinfo = linitial(*effective_relations);
+ if (strcmp(nspname, relinfo->schemaname) == 0 &&
+ strcmp(relname, relinfo->relname) == 0)
+ {
+ pfree(relinfo->schemaname);
+ pfree(relinfo->relname);
+ list_free_deep(*effective_relations);
+ *effective_relations = NIL;
+ }
+ }
+ }
+
/*
* Get relation's row filter expressions. DISTINCT avoids the same
* expression of a table in multiple publications from being included
@@ -1043,6 +1121,7 @@ copy_table(Relation rel)
LogicalRepRelMapEntry *relmapentry;
LogicalRepRelation lrel;
List *qual = NIL;
+ List *effective_relations = NIL;
WalRcvExecResult *res;
StringInfoData cmd;
CopyFromState cstate;
@@ -1054,7 +1133,7 @@ copy_table(Relation rel)
/* Get the publisher relation info. */
fetch_remote_table_info(get_namespace_name(RelationGetNamespace(rel)),
RelationGetRelationName(rel), &lrel, &qual,
- &gencol_published);
+ &gencol_published, &effective_relations);
/* Put the relation into relmap. */
logicalrep_relmap_update(&lrel);
@@ -1066,12 +1145,64 @@ copy_table(Relation rel)
/* Start copy on the publisher. */
initStringInfo(&cmd);
+
+ if (effective_relations && list_length(effective_relations))
+ {
+ bool first = true;
+
+ /*
+ * Build a single COPY command to synchronize all resolved relations
+ * into the root table.
+ *
+ * The array 'effective_relations' contains the leaf tables of
+ * partition hierarchies, with excluded subtrees removed according to
+ * the EXCEPT clauses. This applies only when
+ * 'publish_via_partition_root' is enabled, since the initial sync must
+ * route all changes to the root table.
+ *
+ * We construct a UNION ALL query that combines data from multiple leaf
+ * relations into one sub-COPY statement, ensuring all rows are copied
+ * consistently into the root table.
+ */
+ appendStringInfoString(&cmd, "COPY (\n");
+ foreach_ptr(QualifiedRelationName, relinfo, effective_relations)
+ {
+ if (!first)
+ appendStringInfoString(&cmd, "UNION ALL\n");
+
+ first = false;
+
+ appendStringInfoString(&cmd, "SELECT ");
+
+ /* If the table has columns, then specify the columns */
+ if (lrel.natts)
+ {
+ for (int i = 0; i < lrel.natts; i++)
+ {
+ if (i > 0)
+ appendStringInfoString(&cmd, ", ");
+
+ appendStringInfoString(&cmd, quote_identifier(lrel.attnames[i]));
+ }
+ }
+ else
+ appendStringInfoString(&cmd, " * ");
+
+ appendStringInfo(&cmd, " FROM %s\n",
+ quote_qualified_identifier(relinfo->schemaname,
+ relinfo->relname));
+ }
+
+ appendStringInfoString(&cmd, ")\n");
+ appendStringInfoString(&cmd, "TO STDOUT");
+ }
/* Regular or partitioned table with no row filter or generated columns */
- if ((lrel.relkind == RELKIND_RELATION || lrel.relkind == RELKIND_PARTITIONED_TABLE)
- && qual == NIL && !gencol_published)
+ else if ((lrel.relkind == RELKIND_RELATION ||
+ lrel.relkind == RELKIND_PARTITIONED_TABLE) &&
+ qual == NIL && !gencol_published)
{
appendStringInfo(&cmd, "COPY %s",
- quote_qualified_identifier(lrel.nspname, lrel.relname));
+ quote_qualified_identifier(lrel.nspname, lrel.relname));
/* If the table has columns, then specify the columns */
if (lrel.natts)
@@ -1157,6 +1288,7 @@ copy_table(Relation rel)
}
res = walrcv_exec(LogRepWorkerWalRcvConn, cmd.data, 0, NULL);
+ elog(LOG, "Tablesync worker: Executing query to get the initial sync data:\n%s", cmd.data);
pfree(cmd.data);
if (res->status != WALRCV_OK_COPY_OUT)
ereport(ERROR,
diff --git a/src/backend/replication/pgoutput/pgoutput.c b/src/backend/replication/pgoutput/pgoutput.c
index e016f64e0b3..80df021f3d5 100644
--- a/src/backend/replication/pgoutput/pgoutput.c
+++ b/src/backend/replication/pgoutput/pgoutput.c
@@ -1797,12 +1797,17 @@ pgoutput_shutdown(LogicalDecodingContext *ctx)
* to silently continue the replication in the absence of a missing publication.
* This is required because we allow the users to create publications after they
* have specified the required publications at the time of replication start.
+ * We also enforce that no more than one publication in the list may contain
+ * an EXCEPT TABLE clause. Using multiple publications with exceptions is
+ * currently unsupported to prevent non-deterministic filtering behavior
+ * across overlapping publication sets.
*/
static List *
LoadPublications(List *pubnames)
{
List *result = NIL;
ListCell *lc;
+ List *except_pub_names = NIL;
foreach(lc, pubnames)
{
@@ -1810,7 +1815,13 @@ LoadPublications(List *pubnames)
Publication *pub = GetPublicationByName(pubname, true);
if (pub)
+ {
result = lappend(result, pub);
+
+ /* Check if this publication has an EXCEPT TABLE list. */
+ if (publication_has_any_exception(pub->oid))
+ except_pub_names = lappend(except_pub_names, pstrdup(pubname));
+ }
else
ereport(WARNING,
errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
@@ -1819,6 +1830,34 @@ LoadPublications(List *pubnames)
errhint("Create the publication if it does not exist."));
}
+ /*
+ * If more than one publication has an EXCEPT list, throw an error listing
+ * all the problematic publications.
+ */
+ if (list_length(except_pub_names) > 1)
+ {
+ StringInfoData pub_names_str;
+ bool first = true;
+
+ initStringInfo(&pub_names_str);
+
+ foreach_ptr(char, name, except_pub_names)
+ {
+ if (!first)
+ appendStringInfoString(&pub_names_str, ", ");
+
+ appendStringInfo(&pub_names_str, "\"%s\"", name);
+ first = false;
+ }
+
+ ereport(ERROR,
+ errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+ errmsg("cannot use multiple publications with EXCEPT TABLE lists"),
+ errdetail("The following publications have exceptions: %s.",
+ pub_names_str.data));
+ }
+
+ list_free_deep(except_pub_names);
return result;
}
@@ -2041,6 +2080,65 @@ set_schema_sent_in_streamed_txn(RelationSyncEntry *entry, TransactionId xid)
MemoryContextSwitchTo(oldctx);
}
+/*
+ * publication_has_effective_tables
+ *
+ * Checks if the publication has any effective data in a partition tree.
+ * Returns true if at least one leaf is NOT excluded.
+ */
+static bool
+publication_has_effective_tables(Oid root_relid, Oid pubid)
+{
+ List *all_leaves = NIL;
+ bool at_least_one_allowed = false;
+
+ /* Get all leaf relations in the hierarchy */
+ all_leaves = GetPubPartitionOptionRelations(NIL,
+ PUBLICATION_PART_LEAF,
+ root_relid);
+
+ /*
+ * Traverse leaves. If we find even ONE leaf that is not excluded by the
+ * publication's EXCEPT list, the tree is effective.
+ */
+ foreach_oid(curr_relid, all_leaves)
+ {
+ List *ancestors = get_partition_ancestors(curr_relid);
+ bool is_excluded = false;
+
+ /* 1. Check if the leaf itself is an exception */
+ if (is_relid_published_as_except(curr_relid, pubid))
+ is_excluded = true;
+ else
+ {
+ /* Check if any ancestor is an exception */
+ foreach_oid(ancestor, ancestors)
+ {
+ if (is_relid_published_as_except(ancestor, pubid))
+ {
+ is_excluded = true;
+ break;
+ }
+ }
+ }
+
+ list_free(ancestors);
+
+ /*
+ * If this leaf is not excluded, it means the publication effectively
+ * includes data from this tree.
+ */
+ if (!is_excluded)
+ {
+ at_least_one_allowed = true;
+ break;
+ }
+ }
+
+ list_free(all_leaves);
+ return at_least_one_allowed;
+}
+
/*
* Find or create entry in the relation schema cache.
*
@@ -2088,7 +2186,7 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
if (!entry->replicate_valid)
{
Oid schemaId = get_rel_namespace(relid);
- List *pubids = GetRelationPublications(relid);
+ List *pubids = NIL;
/*
* We don't acquire a lock on the namespace system table as we build
@@ -2102,6 +2200,11 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
bool am_partition = get_rel_relispartition(relid);
char relkind = get_rel_relkind(relid);
List *rel_publications = NIL;
+ bool root_ancestor_published_via_any = false;
+ Oid root_ancestor = InvalidOid;
+ List *ancestors = NIL;
+
+ GetRelationPublications(relid, &pubids, NULL);
/* Reload publications if needed before use. */
if (!publications_valid)
@@ -2180,6 +2283,32 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
entry->estate = NULL;
memset(entry->exprstate, 0, sizeof(entry->exprstate));
+ /*
+ * Pre-calculate if the root ancestor is published via any pubviaroot
+ * publication. This avoids nesting loops inside the main publication
+ * check.
+ */
+ if (am_partition)
+ {
+ ancestors = get_partition_ancestors(relid);
+ root_ancestor = llast_oid(ancestors);
+
+ foreach(lc, data->publications)
+ {
+ Publication *pubinfo = lfirst(lc);
+
+ if (pubinfo->alltables && pubinfo->pubviaroot &&
+ publication_has_any_exception(pubinfo->oid))
+ {
+ if (publication_has_effective_tables(root_ancestor, pubinfo->oid))
+ {
+ root_ancestor_published_via_any = true;
+ break;
+ }
+ }
+ }
+ }
+
/*
* Build publication cache. We can't use one provided by relcache as
* relcache considers all publications that the given relation is in,
@@ -2202,20 +2331,43 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
/*
* If this is a FOR ALL TABLES publication, pick the partition
* root and set the ancestor level accordingly.
+ *
+ * If this is a FOR ALL TABLES publication and it has an EXCEPT
+ * TABLE list:
+ *
+ * If the relation is a partition, check whether the current
+ * relation or any of the ancestors is included in the EXCEPT
+ * TABLE list. If so, do not publish the change. This is done
+ * irrespective of pubviaroot setting.
+ *
+ * "Do not publish the change" is achieved by keeping the variable
+ * "publish" set to false. And eventually, entry->pubactions will
+ * remain all false for this publication.
*/
if (pub->alltables)
{
- publish = true;
- if (pub->pubviaroot && am_partition)
+ List *exceptpubids = NIL;
+
+ if (am_partition)
{
- List *ancestors = get_partition_ancestors(relid);
+ foreach_oid(ancestor, ancestors)
+ GetRelationPublications(ancestor, NULL, &exceptpubids);
- pub_relid = llast_oid(ancestors);
- ancestor_level = list_length(ancestors);
+ if (pub->pubviaroot)
+ {
+ pub_relid = root_ancestor;
+ ancestor_level = list_length(ancestors);
+ }
}
- }
- if (!publish)
+ GetRelationPublications(relid, NULL, &exceptpubids);
+
+ if (!list_member_oid(exceptpubids, pub->oid))
+ publish = true;
+
+ list_free(exceptpubids);
+ }
+ else if (!publish)
{
bool ancestor_published = false;
@@ -2229,19 +2381,32 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
{
Oid ancestor;
int level;
- List *ancestors = get_partition_ancestors(relid);
-
- ancestor = GetTopMostAncestorInPublication(pub->oid,
- ancestors,
- &level);
- if (ancestor != InvalidOid)
+ if (root_ancestor_published_via_any)
{
- ancestor_published = true;
- if (pub->pubviaroot)
+ /*
+ * The root ancestor is already known to be published
+ * via a "FOR ALL TABLES EXCEPT" publication. Publish
+ * via the root table if it is explicitly included, or
+ * if its schema is part of the publication.
+ */
+ pub_relid = root_ancestor;
+ ancestor_level = list_length(ancestors);
+ }
+ else
+ {
+ ancestor = GetTopMostAncestorInPublication(pub->oid,
+ ancestors,
+ &level);
+
+ if (ancestor != InvalidOid)
{
- pub_relid = ancestor;
- ancestor_level = level;
+ ancestor_published = true;
+ if (pub->pubviaroot)
+ {
+ pub_relid = ancestor;
+ ancestor_level = level;
+ }
}
}
}
@@ -2259,6 +2424,9 @@ get_rel_sync_entry(PGOutputData *data, Relation relation)
* Don't publish changes for partitioned tables, because
* publishing those of its partitions suffices, unless partition
* changes won't be published due to pubviaroot being set.
+ *
+ * If the relation is part of EXCEPT TABLE list of a publication,
+ * the 'publish' variable is already set to false.
*/
if (publish &&
(relkind != RELKIND_PARTITIONED_TABLE || pub->pubviaroot))
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index 6b634c9fff1..dc021dbb6cd 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -5788,7 +5788,9 @@ RelationGetExclusionInfo(Relation indexRelation,
void
RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
{
- List *puboids;
+ List *puboids = NIL;
+ List *exceptpuboids = NIL;
+ List *alltablespuboids;
ListCell *lc;
MemoryContext oldcxt;
Oid schemaid;
@@ -5826,7 +5828,7 @@ RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
pubdesc->gencols_valid_for_delete = true;
/* Fetch the publication membership info. */
- puboids = GetRelationPublications(relid);
+ GetRelationPublications(relid, &puboids, &exceptpuboids);
schemaid = RelationGetNamespace(relation);
puboids = list_concat_unique_oid(puboids, GetSchemaPublications(schemaid));
@@ -5838,16 +5840,25 @@ RelationBuildPublicationDesc(Relation relation, PublicationDesc *pubdesc)
foreach(lc, ancestors)
{
Oid ancestor = lfirst_oid(lc);
+ List *ancestor_puboids = NIL;
+ List *ancestor_exceptpuboids = NIL;
- puboids = list_concat_unique_oid(puboids,
- GetRelationPublications(ancestor));
+ GetRelationPublications(ancestor, &ancestor_puboids,
+ &ancestor_exceptpuboids);
+
+ puboids = list_concat_unique_oid(puboids, ancestor_puboids);
schemaid = get_rel_namespace(ancestor);
puboids = list_concat_unique_oid(puboids,
GetSchemaPublications(schemaid));
+ exceptpuboids = list_concat_unique_oid(exceptpuboids,
+ ancestor_exceptpuboids);
}
}
- puboids = list_concat_unique_oid(puboids, GetAllTablesPublications());
+ alltablespuboids = GetAllTablesPublications();
+ puboids = list_concat_unique_oid(puboids,
+ list_difference_oid(alltablespuboids,
+ exceptpuboids));
foreach(lc, puboids)
{
Oid pubid = lfirst_oid(lc);
diff --git a/src/bin/pg_dump/pg_dump.c b/src/bin/pg_dump/pg_dump.c
index 2c3754d020f..b8f9788632a 100644
--- a/src/bin/pg_dump/pg_dump.c
+++ b/src/bin/pg_dump/pg_dump.c
@@ -4635,9 +4635,55 @@ getPublications(Archive *fout)
(strcmp(PQgetvalue(res, i, i_pubviaroot), "t") == 0);
pubinfo[i].pubgencols_type =
*(PQgetvalue(res, i, i_pubgencols));
+ pubinfo[i].except_tables = (SimplePtrList)
+ {
+ NULL, NULL
+ };
/* Decide whether we want to dump it */
selectDumpableObject(&(pubinfo[i].dobj), fout);
+
+ /*
+ * Get the list of tables for publications specified with the EXCEPT
+ * TABLE clause. This is introduced in PostgreSQL 19.
+ *
+ * EXCEPT TABLES is processed here and output directly by
+ * dumpPublication(). This differs from the approach used in
+ * dumpPublicationTable() and dumpPublicationNamespace(), since that
+ * approach would require EXCEPT TABLE support for ALTER PUBLICATION,
+ * which is not currently supported.
+ */
+ if (fout->remoteVersion >= 190000)
+ {
+ int ntbls;
+ PGresult *res_tbls;
+
+ resetPQExpBuffer(query);
+ appendPQExpBuffer(query,
+ "SELECT prrelid\n"
+ "FROM pg_catalog.pg_publication_rel\n"
+ "WHERE prpubid = %u and prexcept",
+ pubinfo[i].dobj.catId.oid);
+
+ res_tbls = ExecuteSqlQuery(fout, query->data, PGRES_TUPLES_OK);
+
+ ntbls = PQntuples(res_tbls);
+
+ for (int j = 0; j < ntbls; j++)
+ {
+ Oid prrelid;
+ TableInfo *tbinfo;
+
+ prrelid = atooid(PQgetvalue(res_tbls, j, 0));
+
+ tbinfo = findTableByOid(prrelid);
+
+ if (tbinfo != NULL)
+ simple_ptr_list_append(&pubinfo[i].except_tables, tbinfo);
+ }
+
+ PQclear(res_tbls);
+ }
}
cleanup:
@@ -4677,7 +4723,25 @@ dumpPublication(Archive *fout, const PublicationInfo *pubinfo)
if (pubinfo->puballtables && pubinfo->puballsequences)
appendPQExpBufferStr(query, " FOR ALL TABLES, ALL SEQUENCES");
else if (pubinfo->puballtables)
+ {
+ int n_excluded = 0;
+
appendPQExpBufferStr(query, " FOR ALL TABLES");
+
+ /* Include EXCEPT TABLE clause if there are except_tables. */
+ for (SimplePtrListCell *cell = pubinfo->except_tables.head; cell; cell = cell->next)
+ {
+ TableInfo *tbinfo = (TableInfo *) cell->ptr;
+
+ if (++n_excluded == 1)
+ appendPQExpBufferStr(query, " EXCEPT TABLE (");
+ else
+ appendPQExpBufferStr(query, ", ");
+ appendPQExpBuffer(query, "ONLY %s", fmtQualifiedDumpable(tbinfo));
+ }
+ if (n_excluded > 0)
+ appendPQExpBufferStr(query, ")");
+ }
else if (pubinfo->puballsequences)
appendPQExpBufferStr(query, " FOR ALL SEQUENCES");
@@ -4857,6 +4921,7 @@ getPublicationTables(Archive *fout, TableInfo tblinfo[], int numTables)
/* Collect all publication membership info. */
if (fout->remoteVersion >= 150000)
+ {
appendPQExpBufferStr(query,
"SELECT tableoid, oid, prpubid, prrelid, "
"pg_catalog.pg_get_expr(prqual, prrelid) AS prrelqual, "
@@ -4869,6 +4934,9 @@ getPublicationTables(Archive *fout, TableInfo tblinfo[], int numTables)
" WHERE attrelid = pr.prrelid AND attnum = prattrs[s])\n"
" ELSE NULL END) prattrs "
"FROM pg_catalog.pg_publication_rel pr");
+ if (fout->remoteVersion >= 190000)
+ appendPQExpBufferStr(query, " WHERE NOT pr.prexcept");
+ }
else
appendPQExpBufferStr(query,
"SELECT tableoid, oid, prpubid, prrelid, "
diff --git a/src/bin/pg_dump/pg_dump.h b/src/bin/pg_dump/pg_dump.h
index 4c4b14e5fc7..d141eb66d17 100644
--- a/src/bin/pg_dump/pg_dump.h
+++ b/src/bin/pg_dump/pg_dump.h
@@ -676,6 +676,7 @@ typedef struct _PublicationInfo
bool pubtruncate;
bool pubviaroot;
PublishGencolsType pubgencols_type;
+ SimplePtrList except_tables;
} PublicationInfo;
/*
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index a8dcc2b5c75..54eae577c91 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -3170,6 +3170,36 @@ my %tests = (
like => { %full_runs, section_post_data => 1, },
},
+ 'CREATE PUBLICATION pub8' => {
+ create_order => 50,
+ create_sql =>
+ 'CREATE PUBLICATION pub8 FOR ALL TABLES EXCEPT (dump_test.test_table);',
+ regexp => qr/^
+ \QCREATE PUBLICATION pub8 FOR ALL TABLES EXCEPT TABLE (ONLY dump_test.test_table) WITH (publish = 'insert, update, delete, truncate');\E
+ /xm,
+ like => { %full_runs, section_post_data => 1, },
+ },
+
+ 'CREATE PUBLICATION pub9' => {
+ create_order => 50,
+ create_sql =>
+ 'CREATE PUBLICATION pub9 FOR ALL TABLES EXCEPT TABLE (dump_test.test_table, dump_test.test_second_table);',
+ regexp => qr/^
+ \QCREATE PUBLICATION pub9 FOR ALL TABLES EXCEPT TABLE (ONLY dump_test.test_table, ONLY dump_test.test_second_table) WITH (publish = 'insert, update, delete, truncate');\E
+ /xm,
+ like => { %full_runs, section_post_data => 1, },
+ },
+
+ 'CREATE PUBLICATION pub10' => {
+ create_order => 92,
+ create_sql =>
+ 'CREATE PUBLICATION pub10 FOR ALL TABLES EXCEPT TABLE (dump_test.test_inheritance_parent);',
+ regexp => qr/^
+ \QCREATE PUBLICATION pub10 FOR ALL TABLES EXCEPT TABLE (ONLY dump_test.test_inheritance_parent, ONLY dump_test.test_inheritance_child) WITH (publish = 'insert, update, delete, truncate');\E
+ /xm,
+ like => { %full_runs, section_post_data => 1, },
+ },
+
'CREATE SUBSCRIPTION sub1' => {
create_order => 50,
create_sql => 'CREATE SUBSCRIPTION sub1
diff --git a/src/bin/psql/describe.c b/src/bin/psql/describe.c
index 3584c4e1428..1bfec0bd3ef 100644
--- a/src/bin/psql/describe.c
+++ b/src/bin/psql/describe.c
@@ -3073,17 +3073,34 @@ describeOneTableDetails(const char *schemaname,
" WHERE attrelid = pr.prrelid AND attnum = prattrs[s])\n"
" ELSE NULL END) "
"FROM pg_catalog.pg_publication p\n"
- " JOIN pg_catalog.pg_publication_rel pr ON p.oid = pr.prpubid\n"
- " JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid\n"
- "WHERE pr.prrelid = '%s'\n"
+ " JOIN pg_catalog.pg_publication_rel pr ON p.oid = pr.prpubid\n"
+ " JOIN pg_catalog.pg_class c ON c.oid = pr.prrelid\n"
+ "WHERE pr.prrelid = '%s'\n",
+ oid, oid, oid);
+
+ if (pset.sversion >= 190000)
+ appendPQExpBufferStr(&buf, " AND NOT pr.prexcept\n");
+
+ appendPQExpBuffer(&buf,
"UNION\n"
"SELECT pubname\n"
- " , NULL\n"
- " , NULL\n"
+ " , NULL\n"
+ " , NULL\n"
"FROM pg_catalog.pg_publication p\n"
- "WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n"
- "ORDER BY 1;",
- oid, oid, oid, oid);
+ "WHERE p.puballtables AND pg_catalog.pg_relation_is_publishable('%s')\n",
+ oid);
+
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf,
+ " AND NOT EXISTS (\n"
+ " SELECT 1\n"
+ " FROM pg_catalog.pg_publication_rel pr\n"
+ " JOIN pg_catalog.pg_class pc\n"
+ " ON pr.prrelid = pc.oid\n"
+ " WHERE pr.prrelid = '%s' AND pr.prpubid = p.oid)\n",
+ oid);
+
+ appendPQExpBufferStr(&buf, "ORDER BY 1;");
}
else
{
@@ -3134,6 +3151,35 @@ describeOneTableDetails(const char *schemaname,
PQclear(result);
}
+ /* Print publications that the table is explicitly excluded from */
+ if (pset.sversion >= 190000)
+ {
+ printfPQExpBuffer(&buf,
+ "SELECT pubname\n"
+ "FROM pg_catalog.pg_publication p\n"
+ "JOIN pg_catalog.pg_publication_rel pr ON p.oid = pr.prpubid\n"
+ "WHERE pr.prrelid = '%s'\n AND pr.prexcept\n"
+ "ORDER BY 1;", oid);
+
+ result = PSQLexec(buf.data);
+ if (!result)
+ goto error_return;
+ else
+ tuples = PQntuples(result);
+
+ if (tuples > 0)
+ printTableAddFooter(&cont, _("Except Publications:"));
+
+ /* Might be an empty set - that's ok */
+ for (i = 0; i < tuples; i++)
+ {
+ printfPQExpBuffer(&buf, " \"%s\"", PQgetvalue(result, i, 0));
+
+ printTableAddFooter(&cont, buf.data);
+ }
+ PQclear(result);
+ }
+
/*
* If verbose, print NOT NULL constraints.
*/
@@ -6753,8 +6799,12 @@ describePublications(const char *pattern)
" pg_catalog.pg_publication_rel pr\n"
"WHERE c.relnamespace = n.oid\n"
" AND c.oid = pr.prrelid\n"
- " AND pr.prpubid = '%s'\n"
- "ORDER BY 1,2", pubid);
+ " AND pr.prpubid = '%s'\n", pubid);
+
+ if (pset.sversion >= 190000)
+ appendPQExpBuffer(&buf, " AND NOT pr.prexcept\n");
+
+ appendPQExpBuffer(&buf, "ORDER BY 1,2");
if (!addFooterToPublicationDesc(&buf, _("Tables:"), false, &cont))
goto error_return;
@@ -6772,6 +6822,23 @@ describePublications(const char *pattern)
goto error_return;
}
}
+ else
+ {
+ if (pset.sversion >= 190000)
+ {
+ /* Get the excluded tables for the specified publication */
+ printfPQExpBuffer(&buf,
+ "SELECT concat(c.relnamespace::regnamespace, '.', c.relname)\n"
+ "FROM pg_catalog.pg_class c\n"
+ " JOIN pg_catalog.pg_publication_rel pr ON c.oid = pr.prrelid\n"
+ "WHERE pr.prpubid = '%s'\n"
+ " AND pr.prexcept\n"
+ "ORDER BY 1", pubid);
+ if (!addFooterToPublicationDesc(&buf, _("Except tables:"),
+ true, &cont))
+ goto error_return;
+ }
+ }
printTable(&cont, pset.queryFout, false, pset.logfile);
printTableCleanup(&cont);
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 8b91bc00062..39404ea0f69 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -3669,7 +3669,17 @@ match_previous_words(int pattern_id,
else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL"))
COMPLETE_WITH("TABLES", "SEQUENCES");
else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL", "TABLES"))
- COMPLETE_WITH("WITH (");
+ COMPLETE_WITH("EXCEPT TABLE (", "WITH (");
+ else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL", "TABLES", "EXCEPT"))
+ COMPLETE_WITH("TABLE (");
+ else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL", "TABLES", "EXCEPT", "TABLE"))
+ COMPLETE_WITH("(");
+ else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL", "TABLES", "EXCEPT", "TABLE", "("))
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_tables);
+ else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL", "TABLES", "EXCEPT", "TABLE", "(", MatchAnyN) && ends_with(prev_wd, ','))
+ COMPLETE_WITH_SCHEMA_QUERY(Query_for_list_of_tables);
+ else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "ALL", "TABLES", "EXCEPT", "TABLE", "(", MatchAnyN) && !ends_with(prev_wd, ','))
+ COMPLETE_WITH(")");
else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLES"))
COMPLETE_WITH("IN SCHEMA");
else if (Matches("CREATE", "PUBLICATION", MatchAny, "FOR", "TABLE", MatchAny) && !ends_with(prev_wd, ','))
diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat
index 83f6501df38..b6da200ffee 100644
--- a/src/include/catalog/pg_proc.dat
+++ b/src/include/catalog/pg_proc.dat
@@ -12336,6 +12336,13 @@
proname => 'pg_relation_is_publishable', provolatile => 's',
prorettype => 'bool', proargtypes => 'regclass',
prosrc => 'pg_relation_is_publishable' },
+{ oid => '9002', descr => 'get effective tables for publication',
+ proname => 'pg_get_publication_effective_tables', prorows => '1000',
+ proretset => 't', provolatile => 's',
+ prorettype => 'record', proargtypes => 'oid _text',
+ proallargtypes => '{oid,_text,text,text}', proargmodes => '{i,i,o,o}',
+ proargnames => '{root_relid,pub_names,schemaname,relname}',
+ prosrc => 'pg_get_publication_effective_tables' },
# rls
{ oid => '3298',
diff --git a/src/include/catalog/pg_publication.h b/src/include/catalog/pg_publication.h
index 368becca899..d9881a9b612 100644
--- a/src/include/catalog/pg_publication.h
+++ b/src/include/catalog/pg_publication.h
@@ -146,14 +146,16 @@ typedef struct PublicationRelInfo
Relation relation;
Node *whereClause;
List *columns;
+ bool except;
} PublicationRelInfo;
extern Publication *GetPublication(Oid pubid);
extern Publication *GetPublicationByName(const char *pubname, bool missing_ok);
-extern List *GetRelationPublications(Oid relid);
+extern bool GetRelationPublications(Oid relid, List **pubids, List **except_pubids);
/*---------
- * Expected values for pub_partopt parameter of GetPublicationRelations(),
+ * Expected values for pub_partopt parameter of
+ * GetPublicationIncludedRelations(), and GetAllPublicationExcludedTables(),
* which allows callers to specify which partitions of partitioned tables
* mentioned in the publication they expect to see.
*
@@ -168,9 +170,12 @@ typedef enum PublicationPartOpt
PUBLICATION_PART_ALL,
} PublicationPartOpt;
-extern List *GetPublicationRelations(Oid pubid, PublicationPartOpt pub_partopt);
+extern List *GetPublicationIncludedRelations(Oid pubid,
+ PublicationPartOpt pub_partopt);
+extern List *GetAllPublicationExcludedTables(Oid pubid,
+ PublicationPartOpt pub_partopt);
extern List *GetAllTablesPublications(void);
-extern List *GetAllPublicationRelations(char relkind, bool pubviaroot);
+extern List *GetAllPublicationRelations(Publication *pub, char relkind);
extern List *GetPublicationSchemas(Oid pubid);
extern List *GetSchemaPublications(Oid schemaid);
extern List *GetSchemaPublicationRelations(Oid schemaid,
@@ -185,6 +190,8 @@ extern Oid GetTopMostAncestorInPublication(Oid puboid, List *ancestors,
extern bool is_publishable_relation(Relation rel);
extern bool is_schema_publication(Oid pubid);
+extern bool is_relid_published_as_except(Oid relid, Oid pubid);
+extern bool publication_has_any_exception(Oid pubid);
extern bool check_and_fetch_column_list(Publication *pub, Oid relid,
MemoryContext mcxt, Bitmapset **cols);
extern ObjectAddress publication_add_relation(Oid pubid, PublicationRelInfo *pri,
diff --git a/src/include/catalog/pg_publication_rel.h b/src/include/catalog/pg_publication_rel.h
index 3a8790e8482..e3ccba5ec79 100644
--- a/src/include/catalog/pg_publication_rel.h
+++ b/src/include/catalog/pg_publication_rel.h
@@ -31,6 +31,7 @@ CATALOG(pg_publication_rel,6106,PublicationRelRelationId)
Oid oid; /* oid */
Oid prpubid BKI_LOOKUP(pg_publication); /* Oid of the publication */
Oid prrelid BKI_LOOKUP(pg_class); /* Oid of the relation */
+ bool prexcept BKI_DEFAULT(f); /* exclude the relation */
#ifdef CATALOG_VARLEN /* variable-length fields start here */
pg_node_tree prqual; /* qualifications */
diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h
index 646d6ced763..5282425b4dd 100644
--- a/src/include/nodes/parsenodes.h
+++ b/src/include/nodes/parsenodes.h
@@ -4300,6 +4300,7 @@ typedef struct PublicationTable
RangeVar *relation; /* relation to be published */
Node *whereClause; /* qualifications */
List *columns; /* List of columns in a publication table */
+ bool except; /* exclude the relation */
} PublicationTable;
/*
@@ -4308,6 +4309,7 @@ typedef struct PublicationTable
typedef enum PublicationObjSpecType
{
PUBLICATIONOBJ_TABLE, /* A table */
+ PUBLICATIONOBJ_EXCEPT_TABLE, /* A table to be excluded */
PUBLICATIONOBJ_TABLES_IN_SCHEMA, /* All tables in schema */
PUBLICATIONOBJ_TABLES_IN_CUR_SCHEMA, /* All tables in first element of
* search_path */
@@ -4336,6 +4338,7 @@ typedef struct PublicationAllObjSpec
{
NodeTag type;
PublicationAllObjType pubobjtype; /* type of this publication object */
+ List *except_tables; /* List of tables to be excluded */
ParseLoc location; /* token location, or -1 if unknown */
} PublicationAllObjSpec;
diff --git a/src/include/replication/worker_internal.h b/src/include/replication/worker_internal.h
index c1285fdd1bc..cab97531276 100644
--- a/src/include/replication/worker_internal.h
+++ b/src/include/replication/worker_internal.h
@@ -234,6 +234,12 @@ typedef struct ParallelApplyWorkerInfo
ParallelApplyWorkerShared *shared;
} ParallelApplyWorkerInfo;
+typedef struct QualifiedRelationName
+{
+ char *schemaname;
+ char *relname;
+} QualifiedRelationName;
+
/* Main memory context for apply worker. Permanent during worker lifetime. */
extern PGDLLIMPORT MemoryContext ApplyContext;
diff --git a/src/test/regress/expected/publication.out b/src/test/regress/expected/publication.out
index 7fb49aaf29b..a8e21e8e9b1 100644
--- a/src/test/regress/expected/publication.out
+++ b/src/test/regress/expected/publication.out
@@ -213,33 +213,103 @@ Not-null constraints:
regress_publication_user | t | f | t | t | f | f | none | f
(1 row)
-DROP TABLE testpub_tbl2;
-DROP PUBLICATION testpub_foralltables, testpub_fortable, testpub_forschema, testpub_for_tbl_schema;
-CREATE TABLE testpub_tbl3 (a int);
-CREATE TABLE testpub_tbl3a (b text) INHERITS (testpub_tbl3);
SET client_min_messages = 'ERROR';
-CREATE PUBLICATION testpub3 FOR TABLE testpub_tbl3;
-CREATE PUBLICATION testpub4 FOR TABLE ONLY testpub_tbl3;
+-- Exclude tables using FOR ALL TABLES EXCEPT TABLE (tablelist)
+CREATE PUBLICATION testpub_foralltables_excepttable FOR ALL TABLES EXCEPT TABLE (testpub_tbl1, testpub_tbl2);
+\dRp+ testpub_foralltables_excepttable
+ Publication testpub_foralltables_excepttable
+ Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
+ regress_publication_user | t | f | t | t | t | t | none | f
+Except tables:
+ "public.testpub_tbl1"
+ "public.testpub_tbl2"
+
+-- Exclude tables using FOR ALL TABLES EXCEPT (tablelist)
+CREATE PUBLICATION testpub_foralltables_excepttable1 FOR ALL TABLES EXCEPT (testpub_tbl1);
+\dRp+ testpub_foralltables_excepttable1
+ Publication testpub_foralltables_excepttable1
+ Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
+ regress_publication_user | t | f | t | t | t | t | none | f
+Except tables:
+ "public.testpub_tbl1"
+
+-- Check that the table description lists what publications are excluding it
+\d testpub_tbl1
+ Table "public.testpub_tbl1"
+ Column | Type | Collation | Nullable | Default
+--------+---------+-----------+----------+------------------------------------------
+ id | integer | | not null | nextval('testpub_tbl1_id_seq'::regclass)
+ data | text | | |
+Indexes:
+ "testpub_tbl1_pkey" PRIMARY KEY, btree (id)
+Publications:
+ "testpub_foralltables"
+Except Publications:
+ "testpub_foralltables_excepttable"
+ "testpub_foralltables_excepttable1"
+
RESET client_min_messages;
+DROP TABLE testpub_tbl2;
+DROP PUBLICATION testpub_foralltables, testpub_fortable, testpub_forschema, testpub_for_tbl_schema, testpub_foralltables_excepttable, testpub_foralltables_excepttable1;
+CREATE TABLE testpub_tbl_parent (a int);
+CREATE TABLE testpub_tbl_child (b text) INHERITS (testpub_tbl_parent);
+SET client_min_messages = 'ERROR';
+CREATE PUBLICATION testpub3 FOR TABLE testpub_tbl_parent;
\dRp+ testpub3
Publication testpub3
Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
regress_publication_user | f | f | t | t | t | t | none | f
Tables:
- "public.testpub_tbl3"
- "public.testpub_tbl3a"
+ "public.testpub_tbl_child"
+ "public.testpub_tbl_parent"
+CREATE PUBLICATION testpub4 FOR TABLE ONLY testpub_tbl_parent;
\dRp+ testpub4
Publication testpub4
Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
regress_publication_user | f | f | t | t | t | t | none | f
Tables:
- "public.testpub_tbl3"
+ "public.testpub_tbl_parent"
-DROP TABLE testpub_tbl3, testpub_tbl3a;
-DROP PUBLICATION testpub3, testpub4;
+-- Exclude parent table, omitting both of 'ONLY' and '*'
+CREATE PUBLICATION testpub5 FOR ALL TABLES EXCEPT TABLE (testpub_tbl_parent);
+\dRp+ testpub5
+ Publication testpub5
+ Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
+ regress_publication_user | t | f | t | t | t | t | none | f
+Except tables:
+ "public.testpub_tbl_child"
+ "public.testpub_tbl_parent"
+
+-- EXCEPT with '*': exclude table and all descendants
+CREATE PUBLICATION testpub6 FOR ALL TABLES EXCEPT TABLE (testpub_tbl_parent *);
+\dRp+ testpub6
+ Publication testpub6
+ Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
+ regress_publication_user | t | f | t | t | t | t | none | f
+Except tables:
+ "public.testpub_tbl_child"
+ "public.testpub_tbl_parent"
+
+-- EXCEPT with ONLY: exclude table but not descendants
+CREATE PUBLICATION testpub7 FOR ALL TABLES EXCEPT TABLE (ONLY testpub_tbl_parent);
+\dRp+ testpub7
+ Publication testpub7
+ Owner | All tables | All sequences | Inserts | Updates | Deletes | Truncates | Generated columns | Via root
+--------------------------+------------+---------------+---------+---------+---------+-----------+-------------------+----------
+ regress_publication_user | t | f | t | t | t | t | none | f
+Except tables:
+ "public.testpub_tbl_parent"
+
+RESET client_min_messages;
+DROP TABLE testpub_tbl_parent, testpub_tbl_child;
+DROP PUBLICATION testpub3, testpub4, testpub5, testpub6, testpub7;
--- Tests for publications with SEQUENCES
CREATE SEQUENCE regress_pub_seq0;
CREATE SEQUENCE pub_test.regress_pub_seq1;
diff --git a/src/test/regress/sql/publication.sql b/src/test/regress/sql/publication.sql
index 85b00bd67c8..f790fe49518 100644
--- a/src/test/regress/sql/publication.sql
+++ b/src/test/regress/sql/publication.sql
@@ -105,20 +105,40 @@ SELECT pubname, puballtables FROM pg_publication WHERE pubname = 'testpub_forall
\d+ testpub_tbl2
\dRp+ testpub_foralltables
+SET client_min_messages = 'ERROR';
+-- Exclude tables using FOR ALL TABLES EXCEPT TABLE (tablelist)
+CREATE PUBLICATION testpub_foralltables_excepttable FOR ALL TABLES EXCEPT TABLE (testpub_tbl1, testpub_tbl2);
+\dRp+ testpub_foralltables_excepttable
+-- Exclude tables using FOR ALL TABLES EXCEPT (tablelist)
+CREATE PUBLICATION testpub_foralltables_excepttable1 FOR ALL TABLES EXCEPT (testpub_tbl1);
+\dRp+ testpub_foralltables_excepttable1
+-- Check that the table description lists what publications are excluding it
+\d testpub_tbl1
+
+RESET client_min_messages;
DROP TABLE testpub_tbl2;
-DROP PUBLICATION testpub_foralltables, testpub_fortable, testpub_forschema, testpub_for_tbl_schema;
+DROP PUBLICATION testpub_foralltables, testpub_fortable, testpub_forschema, testpub_for_tbl_schema, testpub_foralltables_excepttable, testpub_foralltables_excepttable1;
-CREATE TABLE testpub_tbl3 (a int);
-CREATE TABLE testpub_tbl3a (b text) INHERITS (testpub_tbl3);
+CREATE TABLE testpub_tbl_parent (a int);
+CREATE TABLE testpub_tbl_child (b text) INHERITS (testpub_tbl_parent);
SET client_min_messages = 'ERROR';
-CREATE PUBLICATION testpub3 FOR TABLE testpub_tbl3;
-CREATE PUBLICATION testpub4 FOR TABLE ONLY testpub_tbl3;
-RESET client_min_messages;
+CREATE PUBLICATION testpub3 FOR TABLE testpub_tbl_parent;
\dRp+ testpub3
+CREATE PUBLICATION testpub4 FOR TABLE ONLY testpub_tbl_parent;
\dRp+ testpub4
+-- Exclude parent table, omitting both of 'ONLY' and '*'
+CREATE PUBLICATION testpub5 FOR ALL TABLES EXCEPT TABLE (testpub_tbl_parent);
+\dRp+ testpub5
+-- EXCEPT with '*': exclude table and all descendants
+CREATE PUBLICATION testpub6 FOR ALL TABLES EXCEPT TABLE (testpub_tbl_parent *);
+\dRp+ testpub6
+-- EXCEPT with ONLY: exclude table but not descendants
+CREATE PUBLICATION testpub7 FOR ALL TABLES EXCEPT TABLE (ONLY testpub_tbl_parent);
+\dRp+ testpub7
-DROP TABLE testpub_tbl3, testpub_tbl3a;
-DROP PUBLICATION testpub3, testpub4;
+RESET client_min_messages;
+DROP TABLE testpub_tbl_parent, testpub_tbl_child;
+DROP PUBLICATION testpub3, testpub4, testpub5, testpub6, testpub7;
--- Tests for publications with SEQUENCES
CREATE SEQUENCE regress_pub_seq0;
diff --git a/src/test/subscription/meson.build b/src/test/subscription/meson.build
index a4c7dbaff59..07282aa3c18 100644
--- a/src/test/subscription/meson.build
+++ b/src/test/subscription/meson.build
@@ -46,6 +46,7 @@ tests += {
't/034_temporal.pl',
't/035_conflicts.pl',
't/036_sequences.pl',
+ 't/037_rep_changes_except_table.pl',
't/100_bugs.pl',
],
},
diff --git a/src/test/subscription/t/037_rep_changes_except_table.pl b/src/test/subscription/t/037_rep_changes_except_table.pl
new file mode 100644
index 00000000000..f9436c9e50e
--- /dev/null
+++ b/src/test/subscription/t/037_rep_changes_except_table.pl
@@ -0,0 +1,314 @@
+
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Logical replication tests for EXCEPT TABLE publications
+use strict;
+use warnings;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Initialize publisher node
+my $node_publisher = PostgreSQL::Test::Cluster->new('publisher');
+$node_publisher->init(allows_streaming => 'logical');
+$node_publisher->start;
+
+# Initialize subscriber node
+my $node_subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$node_subscriber->init(allows_streaming => 'logical');
+$node_subscriber->start;
+
+# ============================================
+# EXCEPT TABLE test cases for normal tables
+# ============================================
+# Create schemas and tables on publisher
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE SCHEMA sch1;
+ CREATE TABLE sch1.tab1 AS SELECT generate_series(1,10) AS a;
+));
+
+# Create schemas and tables on subscriber
+$node_subscriber->safe_psql(
+ 'postgres', qq(
+ CREATE SCHEMA sch1;
+ CREATE TABLE sch1.tab1 (a int);
+));
+
+# Setup logical replication, and create a logical replication slot to help with
+# later tests.
+my $publisher_connstr = $node_publisher->connstr . ' dbname=postgres';
+$node_publisher->safe_psql('postgres',
+ "CREATE PUBLICATION tap_pub_schema FOR ALL TABLES EXCEPT TABLE (sch1.tab1)"
+);
+
+$node_publisher->safe_psql('postgres',
+ "SELECT pg_create_logical_replication_slot('test_slot', 'pgoutput')");
+
+$node_subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION tap_sub_schema CONNECTION '$publisher_connstr' PUBLICATION tap_pub_schema"
+);
+
+# Wait for initial table sync to finish
+$node_subscriber->wait_for_subscription_sync($node_publisher,
+ 'tap_sub_schema');
+
+# Check the table data does not sync for excluded table
+my $result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*), min(a), max(a) FROM sch1.tab1");
+is($result, qq(0||),
+ 'check there is no initial data copied for the excluded table');
+
+# Insert some data to the excluded table.
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO sch1.tab1 VALUES(generate_series(11,20))");
+
+# Verify that data inserted to excluded table is not published.
+$result = $node_publisher->safe_psql('postgres',
+ "SELECT count(*) = 0 FROM pg_logical_slot_get_binary_changes('test_slot', NULL, NULL, 'proto_version', '1', 'publication_names', 'tap_sub_schema')"
+);
+is($result, qq(t), 'check no changes for excluded table in replication slot');
+
+# Verify that data inserted to the excluded table is not replicated.
+$node_publisher->wait_for_catchup('tap_sub_schema');
+
+$result = $node_subscriber->safe_psql('postgres',
+ "SELECT count(*), min(a), max(a) FROM sch1.tab1");
+is($result, qq(0||), 'check replicated inserts on subscriber');
+
+# cleanup
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub_schema");
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ DROP PUBLICATION tap_pub_schema;
+ TRUNCATE TABLE sch1.tab1;
+));
+$node_subscriber->safe_psql('postgres', "TRUNCATE TABLE sch1.tab1");
+
+# ============================================
+# EXCEPT TABLE test cases for partitioned tables
+# Check behavior of EXCEPT TABLE with publish_via_partition_root on a
+# partitioned table and its partitions.
+# ============================================
+# Setup partitioned table and partitions on the publisher that map to normal
+# tables on the subscriber
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE TABLE sch1.t1(a int) PARTITION BY RANGE(a);
+ CREATE TABLE sch1.part1 PARTITION OF sch1.t1 FOR VALUES FROM (0) TO (100);
+ CREATE TABLE sch1.part2(a int) PARTITION BY RANGE(a);
+ CREATE TABLE sch1.part2_1 PARTITION OF sch1.part2 FOR VALUES FROM (101) TO (150);
+ CREATE TABLE sch1.part2_2 PARTITION OF sch1.part2 FOR VALUES FROM (151) TO (200);
+ ALTER TABLE sch1.t1 ATTACH PARTITION sch1.part2 FOR VALUES FROM (101) TO (200);
+));
+
+$node_subscriber->safe_psql(
+ 'postgres', qq(
+ CREATE TABLE sch1.t1(a int);
+ CREATE TABLE sch1.part1(a int);
+ CREATE TABLE sch1.part2(a int);
+ CREATE TABLE sch1.part2_1(a int);
+ CREATE TABLE sch1.part2_2(a int);
+));
+
+# Excluding the root partitioned table excludes all its partitions as well when
+# publish_via_partition_root = false.
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE PUBLICATION tap_pub_part FOR ALL TABLES EXCEPT TABLE (sch1.t1) WITH (publish_via_partition_root = false);
+ INSERT INTO sch1.t1 VALUES (1), (101), (151);
+));
+$node_subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION tap_sub_part CONNECTION '$publisher_connstr' PUBLICATION tap_pub_part"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'tap_sub_part');
+$node_publisher->safe_psql('postgres',
+ "SELECT slot_name FROM pg_replication_slot_advance('test_slot', pg_current_wal_lsn());"
+);
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO sch1.t1 VALUES (2), (102), (152)");
+
+# Verify that data inserted to the partitioned table is not published when it is
+# excluded with publish_via_partition_root = true.
+$result = $node_publisher->safe_psql('postgres',
+ "SELECT count(*) = 0 FROM pg_logical_slot_get_binary_changes('test_slot', NULL, NULL, 'proto_version', '1', 'publication_names', 'tap_pub_part')"
+);
+$node_publisher->wait_for_catchup('tap_sub_part');
+
+# Check that no rows are replicated to subscriber
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.t1");
+is($result, qq(), 'check rows on root table');
+
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part1");
+is($result, qq(), 'check rows on table sch1.part1');
+
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2");
+is($result, qq(), 'check rows on table sch1.part2');
+
+$result =
+ $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2_1");
+is($result, qq(), 'check rows on table sch1.part2_1');
+
+$result =
+ $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2_2");
+is($result, qq(), 'check rows on table sch1.part2_2');
+
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub_part");
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION tap_pub_part;");
+
+# Excluding the root partitioned table excludes all its partitions as well when
+# publish_via_partition_root = true.
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE PUBLICATION tap_pub_part FOR ALL TABLES EXCEPT TABLE (sch1.t1) WITH (publish_via_partition_root = true);
+));
+$node_subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION tap_sub_part CONNECTION '$publisher_connstr' PUBLICATION tap_pub_part"
+);
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'tap_sub_part');
+$node_publisher->safe_psql('postgres',
+ "SELECT slot_name FROM pg_replication_slot_advance('test_slot', pg_current_wal_lsn());"
+);
+$node_publisher->safe_psql('postgres',
+ "INSERT INTO sch1.t1 VALUES (3), (103), (153);");
+
+# Verify that data inserted to the partitioned table is not published when it is
+# excluded with publish_via_partition_root = true.
+$result = $node_publisher->safe_psql('postgres',
+ "SELECT count(*) = 0 FROM pg_logical_slot_get_binary_changes('test_slot', NULL, NULL, 'proto_version', '1', 'publication_names', 'tap_pub_part')"
+);
+$node_publisher->wait_for_catchup('tap_sub_part');
+
+# Check that no rows are replicated to subscriber
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.t1");
+is($result, qq(), 'check rows on root table');
+
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part1");
+is($result, qq(), 'check rows on table sch1.part1');
+
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2");
+is($result, qq(), 'check rows on table sch1.part2');
+
+$result =
+ $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2_1");
+is($result, qq(), 'check rows on table sch1.part2_1');
+
+$result =
+ $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2_2");
+is($result, qq(), 'check rows on table sch1.part2_2');
+
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub_part");
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION tap_pub_part;");
+$node_publisher->safe_psql('postgres',
+ "SELECT slot_name FROM pg_replication_slot_advance('test_slot', pg_current_wal_lsn());"
+);
+
+# Excluding one of the child partition table with
+# publish_via_partition_root = true should replicate the other partitions.
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ TRUNCATE sch1.t1;
+ INSERT INTO sch1.t1 VALUES (3), (103), (153);
+ CREATE PUBLICATION tap_pub_part FOR ALL TABLES EXCEPT TABLE (sch1.part2) WITH (publish_via_partition_root = true);
+));
+$node_subscriber->safe_psql(
+ 'postgres', qq(
+ TRUNCATE sch1.t1;
+ CREATE SUBSCRIPTION tap_sub_part CONNECTION '$publisher_connstr' PUBLICATION tap_pub_part;
+));
+$node_subscriber->wait_for_subscription_sync($node_publisher, 'tap_sub_part');
+$node_publisher->safe_psql('postgres',
+ "SELECT slot_name FROM pg_replication_slot_advance('test_slot', pg_current_wal_lsn());"
+);
+
+# Verify that data inserted to the partitioned table is not published when it is
+# excluded with publish_via_partition_root = true.
+$result = $node_publisher->safe_psql('postgres',
+ "SELECT count(*) = 0 FROM pg_logical_slot_get_binary_changes('test_slot', NULL, NULL, 'proto_version', '1', 'publication_names', 'tap_pub_part')"
+);
+$node_publisher->wait_for_catchup('tap_sub_part');
+
+# Check that table data 103 and 153 which is present in sch1.sch1.part2 should
+# not be replicated.
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.t1");
+is($result, qq(3), 'check rows on root table');
+
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part1");
+is($result, qq(), 'check rows on table sch1.part1');
+
+$result = $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2");
+is($result, qq(), 'check rows on table sch1.part2');
+
+$result =
+ $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2_1");
+is($result, qq(), 'check rows on table sch1.part2_1');
+
+$result =
+ $node_subscriber->safe_psql('postgres', "SELECT * FROM sch1.part2_2");
+is($result, qq(), 'check rows on table sch1.part2_2');
+
+$node_subscriber->safe_psql('postgres', "DROP SUBSCRIPTION tap_sub_part");
+$node_publisher->safe_psql('postgres', "DROP PUBLICATION tap_pub_part;");
+
+# ============================================
+# Test when a subscription is subscribing to multiple publications
+# ============================================
+# Multiple publications with EXCEPT lists should throw an error
+my ($stdout, $stderr);
+
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE PUBLICATION tap_pub1 FOR ALL TABLES EXCEPT (sch1.tab1);
+ CREATE PUBLICATION tap_pub2 FOR ALL TABLES EXCEPT (sch1.t1);
+));
+
+($result, $stdout, $stderr) = $node_subscriber->psql('postgres',
+ "CREATE SUBSCRIPTION tap_sub CONNECTION '$publisher_connstr' PUBLICATION tap_pub1, tap_pub2"
+);
+like(
+ $stderr,
+ qr/ERROR: publications "tap_pub1", "tap_pub2" are defined with EXCEPT TABLE/,
+ 'subscription with multiple EXCEPT TABLE publication');
+
+$node_publisher->safe_psql('postgres', 'DROP PUBLICATION tap_pub2');
+
+# A table is exclude using ALL TABLE .. EXCEPT TABLE but included using FOR
+# TABLE
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ CREATE PUBLICATION tap_pub2 FOR TABLE sch1.tab1;
+ INSERT INTO sch1.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 sch1.tab1 VALUES(2)));
+$node_publisher->wait_for_catchup('tap_sub');
+
+$result = $node_publisher->safe_psql('postgres',
+ "SELECT * FROM sch1.tab1 ORDER BY a");
+is( $result, qq(1
+2),
+ "check replication of table excluded a publication and included by another"
+);
+
+# Alter subscription refresh throws error when subscription has multiple
+# publications with EXCEPT TABLE
+$node_publisher->safe_psql(
+ 'postgres', qq(
+ DROP PUBLICATION tap_pub2;
+ CREATE PUBLICATION tap_pub2 FOR ALL TABLES EXCEPT (sch1.t1);
+));
+
+($result, $stdout, $stderr) = $node_subscriber->psql('postgres',
+ "ALTER SUBSCRIPTION tap_sub REFRESH PUBLICATION");
+like(
+ $stderr,
+ qr/ERROR: publications "tap_pub1", "tap_pub2" are defined with EXCEPT TABLE/,
+ 'subscription with multiple EXCEPT TABLE publication');
+
+$node_publisher->stop('fast');
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 7619845fba9..57aabae24a7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2419,6 +2419,7 @@ QTNode
QUERYTYPE
QualCost
QualItem
+QualifiedRelationName
Query
QueryCompletion
QueryDesc
--
2.43.0
[application/octet-stream] v42-0002-Extended-tests-for-EXCEPT-TABLE-patch.patch (4.9K, 3-v42-0002-Extended-tests-for-EXCEPT-TABLE-patch.patch)
download | inline diff:
From 6cef0a564d897d1a9cd7dffa68da7cbf73124210 Mon Sep 17 00:00:00 2001
From: Shlok Kyal <[email protected]>
Date: Wed, 4 Feb 2026 12:27:57 +0530
Subject: [PATCH v42 2/2] Extended tests for EXCEPT TABLE patch
This patch contains extra tests for EXCEPT TABLE patch. Purpose of this
tests to keep track of tests which once broke the patch.
Based on discussion in future we may pick up some test to include in
the main patch.
---
src/test/subscription/t/101_test.pl | 136 ++++++++++++++++++++++++++++
1 file changed, 136 insertions(+)
create mode 100644 src/test/subscription/t/101_test.pl
diff --git a/src/test/subscription/t/101_test.pl b/src/test/subscription/t/101_test.pl
new file mode 100644
index 00000000000..6e4dfc3a713
--- /dev/null
+++ b/src/test/subscription/t/101_test.pl
@@ -0,0 +1,136 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Extended test for EXCEPT TABLE
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# --------
+# Publisher
+# --------
+my $publisher = PostgreSQL::Test::Cluster->new('publisher');
+$publisher->init(allows_streaming => 'logical');
+$publisher->append_conf(
+ 'postgresql.conf', q(
+wal_level = logical
+log_min_messages = INFO
+log_statement = all
+wal_receiver_timeout = 0
+wal_sender_timeout = 0
+));
+$publisher->start;
+
+# Create a partition structure:
+# sc1.t1
+# - sc1.child1
+# - sc1.child1_1
+# - sc1.child1_2
+# - sc1.child2
+$publisher->safe_psql(
+ 'postgres', q(
+ CREATE SCHEMA sc1;
+ CREATE TABLE sc1.t1(id int) PARTITION BY RANGE(id);
+ CREATE TABLE sc1.child1(id int) PARTITION BY RANGE(id);
+ CREATE TABLE sc1.child2 PARTITION OF sc1.t1 FOR VALUES FROM (101) TO (200);
+ CREATE TABLE sc1.child1_1 PARTITION OF sc1.child1 FOR VALUES FROM (0) TO (50);
+ CREATE TABLE sc1.child1_2 PARTITION OF sc1.child1 FOR VALUES FROM (51) TO (100);
+ ALTER TABLE sc1.t1 ATTACH PARTITION sc1.child1 FOR VALUES FROM (0) TO (100);
+));
+
+# ----------
+# Subscriber
+# ----------
+my $connstr = $publisher->connstr . ' dbname=postgres';
+my $subscriber = PostgreSQL::Test::Cluster->new('subscriber');
+$subscriber->init(allows_streaming => 'logical');
+$subscriber->append_conf(
+ 'postgresql.conf', q(
+wal_level = logical
+log_min_messages = INFO
+log_statement = all
+wal_receiver_timeout = 0
+wal_sender_timeout = 0
+));
+$subscriber->start;
+
+$subscriber->safe_psql(
+ 'postgres', q(
+ CREATE SCHEMA sc1;
+ CREATE TABLE sc1.t1(id int);
+ CREATE TABLE sc1.child1(id int);
+ CREATE TABLE sc1.child1_1(id int);
+ CREATE TABLE sc1.child1_2(id int);
+ CREATE TABLE sc1.child2(id int);
+));
+
+# Test 1:
+# Create two publications, one with "ALL TABLES EXCEPT", other with "FOR TABLE"
+# Tables specified in "EXCEPT" and "FOR TABLE" are part of same partition tree
+$publisher->safe_psql(
+ 'postgres', q(
+ INSERT INTO sc1.t1 VALUES (1), (51), (101);
+ CREATE PUBLICATION pub1 FOR ALL TABLES EXCEPT TABLE (sc1.child2) WITH (publish_via_partition_root = true);
+ CREATE PUBLICATION pub2 FOR TABLE sc1.child1_1 WITH (publish_via_partition_root = true);
+));
+$subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION sub1 CONNECTION '$connstr' PUBLICATION pub1, pub2;");
+
+# Check for tablesync
+$subscriber->wait_for_subscription_sync($publisher, 'sub1');
+my $result =
+ $subscriber->safe_psql('postgres', "SELECT * FROM sc1.t1 ORDER BY id");
+is( $result, qq(1
+51), 'check data after tablesync');
+
+# Check for incremental sync
+$publisher->safe_psql('postgres',
+ "INSERT INTO sc1.t1 VALUES (1), (51), (101)");
+$subscriber->wait_for_subscription_sync($publisher, 'sub1');
+$result =
+ $subscriber->safe_psql('postgres', "SELECT * FROM sc1.t1 ORDER BY id");
+is( $result, qq(1
+1
+51
+51), 'check data after incremental sync');
+
+# cleanup
+$publisher->safe_psql(
+ 'postgres', q(
+ DROP PUBLICATION pub1, pub2;
+ TRUNCATE sc1.t1;
+));
+$subscriber->safe_psql(
+ 'postgres', q(
+ DROP SUBSCRIPTION sub1;
+ TRUNCATE sc1.t1;
+));
+
+# Test 2: Create publication with EXCEPT TABLE list having all partitions of
+# particular level
+$publisher->safe_psql(
+ 'postgres', q(
+ INSERT INTO sc1.t1 VALUES (1), (51), (101);
+ CREATE PUBLICATION pub1 FOR ALL TABLES EXCEPT TABLE (sc1.child2, sc1.child1) WITH (publish_via_partition_root = true);
+));
+$subscriber->safe_psql('postgres',
+ "CREATE SUBSCRIPTION sub1 CONNECTION '$connstr' PUBLICATION pub1;");
+
+# Check for tablesync
+$subscriber->wait_for_subscription_sync($publisher, 'sub1');
+$result =
+ $subscriber->safe_psql('postgres', "SELECT * FROM sc1.t1 ORDER BY id");
+is($result, qq(), 'check data after tablesync');
+
+# Check for incremental sync
+$publisher->safe_psql('postgres',
+ "INSERT INTO sc1.t1 VALUES (1), (51), (101)");
+$subscriber->wait_for_subscription_sync($publisher, 'sub1');
+$result =
+ $subscriber->safe_psql('postgres', "SELECT * FROM sc1.t1 ORDER BY id");
+is($result, qq(), 'check data after incremental sync');
+
+done_testing();
--
2.43.0
view thread (368+ messages) latest in thread
reply
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Reply to all the recipients using the --to and --cc options:
reply via email
To: [email protected]
Cc: [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected]
Subject: Re: Skipping schema changes in publication
In-Reply-To: <CALDaNm2EU7qdEnGmirzVn9a1O-6y3q5c7nzsBZCuyVjaPS37mg@mail.gmail.com>
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox