Skip to main content

pgevolve_core/ir/
publication.rs

1//! Publication IR — declarative logical-replication source-side metadata.
2//!
3//! A `Publication` is a Postgres `CREATE PUBLICATION` object. It lives at
4//! the Catalog top level (not schema-qualified) because Postgres treats
5//! publications as a per-database global namespace.
6//!
7//! Spec: `docs/superpowers/specs/2026-05-26-publications-design.md`.
8
9use std::collections::BTreeSet;
10
11use serde::{Deserialize, Serialize};
12
13use crate::identifier::{Identifier, QualifiedName};
14use crate::ir::default_expr::NormalizedExpr;
15use crate::ir::difference::Difference;
16use crate::ir::eq::{Equiv, field_difference};
17
18/// Declarative model of a Postgres `PUBLICATION`.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Publication {
21    /// Publication name (not schema-qualified — publications are global).
22    pub name: Identifier,
23    /// Which tables / schemas are published.
24    pub scope: PublicationScope,
25    /// Which DML kinds are replicated.
26    pub publish: PublishKinds,
27    /// Whether `INSERT`/`UPDATE`/`DELETE` on partition children should be
28    /// reported using the partition root's identity (PG 13+).
29    pub publish_via_partition_root: bool,
30    /// Object owner. `None` = unmanaged (the differ ignores ownership).
31    /// `Some(role)` = managed: diff emits `ALTER PUBLICATION ... OWNER TO role`.
32    pub owner: Option<Identifier>,
33    /// Optional comment.
34    pub comment: Option<String>,
35}
36
37impl Equiv for Publication {
38    fn differences(&self, other: &Self) -> Vec<Difference> {
39        // Field-completeness guard: the compiler errors if a field is added
40        // without being handled below. Bindings are unused (read via `self`).
41        let Self {
42            name: _,
43            scope: _,
44            publish: _,
45            publish_via_partition_root: _,
46            owner: _,
47            comment: _,
48        } = self;
49        let mut out = Vec::new();
50        out.extend(field_difference("name", &self.name, &other.name));
51        out.extend(field_difference(
52            "scope",
53            &format!("{:?}", self.scope),
54            &format!("{:?}", other.scope),
55        ));
56        out.extend(field_difference(
57            "publish",
58            &format!("{:?}", self.publish),
59            &format!("{:?}", other.publish),
60        ));
61        out.extend(field_difference(
62            "publish_via_partition_root",
63            &format!("{:?}", self.publish_via_partition_root),
64            &format!("{:?}", other.publish_via_partition_root),
65        ));
66        out.extend(field_difference(
67            "owner",
68            &format!("{:?}", self.owner),
69            &format!("{:?}", other.owner),
70        ));
71        out.extend(field_difference(
72            "comment",
73            &format!("{:?}", self.comment),
74            &format!("{:?}", other.comment),
75        ));
76        out
77    }
78}
79
80/// Target set of a publication. Encodes PG's mutual exclusion of
81/// `FOR ALL TABLES` and the selective forms at the type level.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub enum PublicationScope {
84    /// `CREATE PUBLICATION p FOR ALL TABLES`. Implicitly captures every
85    /// current and future table in the database.
86    AllTables,
87    /// `CREATE PUBLICATION p FOR TABLE ..., TABLES IN SCHEMA ...`.
88    /// Either list may be empty (but not both — canon rejects empty
89    /// Selective). Schema-scope is PG 15+ only.
90    Selective {
91        /// Schemas published in their entirety. PG 15+.
92        schemas: BTreeSet<Identifier>,
93        /// Per-table publication entries with optional row filter and
94        /// column list. Sorted by `qname` after canon.
95        tables: Vec<PublishedTable>,
96    },
97}
98
99/// A single table entry inside `PublicationScope::Selective`.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct PublishedTable {
102    /// Schema-qualified table name.
103    pub qname: QualifiedName,
104    /// Optional `WHERE` row filter (PG 15+). Canonicalized via
105    /// `NormalizedExpr`.
106    pub row_filter: Option<NormalizedExpr>,
107    /// Optional explicit column list (PG 15+). Sorted by name after canon.
108    /// `None` = all columns; `Some(empty)` is rejected by canon.
109    pub columns: Option<Vec<Identifier>>,
110}
111
112/// Which DML kinds a publication replicates. Maps to PG's four
113/// `pg_publication.pub{insert,update,delete,truncate}` booleans, and the
114/// source SQL `publish = 'insert, update, delete, truncate'` parameter.
115///
116/// The four `bool` fields directly model the four PG catalog columns; a
117/// bitset enum would be less readable and harder to (de)serialize from
118/// `pg_publication`.
119#[allow(clippy::struct_excessive_bools)]
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121pub struct PublishKinds {
122    /// `INSERT` is replicated.
123    pub insert: bool,
124    /// `UPDATE` is replicated.
125    pub update: bool,
126    /// `DELETE` is replicated.
127    pub delete: bool,
128    /// `TRUNCATE` is replicated.
129    pub truncate: bool,
130}
131
132impl PublishKinds {
133    /// PG's `CREATE PUBLICATION` default when `publish` is unspecified:
134    /// all four DML kinds enabled.
135    #[must_use]
136    pub const fn pg_default() -> Self {
137        Self {
138            insert: true,
139            update: true,
140            delete: true,
141            truncate: true,
142        }
143    }
144
145    /// True iff at least one DML kind is enabled. An empty bitset is
146    /// illegal at the IR level (canon rejects).
147    #[must_use]
148    pub const fn is_empty(&self) -> bool {
149        !self.insert && !self.update && !self.delete && !self.truncate
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::identifier::Identifier;
157
158    fn id(s: &str) -> Identifier {
159        Identifier::from_unquoted(s).unwrap()
160    }
161
162    #[test]
163    fn publish_kinds_default_all_true() {
164        let k = PublishKinds::pg_default();
165        assert!(k.insert && k.update && k.delete && k.truncate);
166        assert!(!k.is_empty());
167    }
168
169    #[test]
170    fn publish_kinds_is_empty_when_all_false() {
171        let k = PublishKinds {
172            insert: false,
173            update: false,
174            delete: false,
175            truncate: false,
176        };
177        assert!(k.is_empty());
178    }
179
180    #[test]
181    fn scope_all_tables_does_not_equal_empty_selective() {
182        let a = PublicationScope::AllTables;
183        let b = PublicationScope::Selective {
184            schemas: BTreeSet::new(),
185            tables: Vec::new(),
186        };
187        assert_ne!(a, b);
188    }
189
190    #[test]
191    fn selective_with_a_schema_equals_itself() {
192        let s = PublicationScope::Selective {
193            schemas: BTreeSet::from([id("app")]),
194            tables: Vec::new(),
195        };
196        assert_eq!(s.clone(), s);
197    }
198}