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;
15
16/// Declarative model of a Postgres `PUBLICATION`.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Publication {
19    /// Publication name (not schema-qualified — publications are global).
20    pub name: Identifier,
21    /// Which tables / schemas are published.
22    pub scope: PublicationScope,
23    /// Which DML kinds are replicated.
24    pub publish: PublishKinds,
25    /// Whether `INSERT`/`UPDATE`/`DELETE` on partition children should be
26    /// reported using the partition root's identity (PG 13+).
27    pub publish_via_partition_root: bool,
28    /// Object owner. `None` = unmanaged (the differ ignores ownership).
29    /// `Some(role)` = managed: diff emits `ALTER PUBLICATION ... OWNER TO role`.
30    pub owner: Option<Identifier>,
31    /// Optional comment.
32    pub comment: Option<String>,
33}
34
35/// Target set of a publication. Encodes PG's mutual exclusion of
36/// `FOR ALL TABLES` and the selective forms at the type level.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum PublicationScope {
39    /// `CREATE PUBLICATION p FOR ALL TABLES`. Implicitly captures every
40    /// current and future table in the database.
41    AllTables,
42    /// `CREATE PUBLICATION p FOR TABLE ..., TABLES IN SCHEMA ...`.
43    /// Either list may be empty (but not both — canon rejects empty
44    /// Selective). Schema-scope is PG 15+ only.
45    Selective {
46        /// Schemas published in their entirety. PG 15+.
47        schemas: BTreeSet<Identifier>,
48        /// Per-table publication entries with optional row filter and
49        /// column list. Sorted by `qname` after canon.
50        tables: Vec<PublishedTable>,
51    },
52}
53
54/// A single table entry inside `PublicationScope::Selective`.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct PublishedTable {
57    /// Schema-qualified table name.
58    pub qname: QualifiedName,
59    /// Optional `WHERE` row filter (PG 15+). Canonicalized via
60    /// `NormalizedExpr`.
61    pub row_filter: Option<NormalizedExpr>,
62    /// Optional explicit column list (PG 15+). Sorted by name after canon.
63    /// `None` = all columns; `Some(empty)` is rejected by canon.
64    pub columns: Option<Vec<Identifier>>,
65}
66
67/// Which DML kinds a publication replicates. Maps to PG's four
68/// `pg_publication.pub{insert,update,delete,truncate}` booleans, and the
69/// source SQL `publish = 'insert, update, delete, truncate'` parameter.
70///
71/// The four `bool` fields directly model the four PG catalog columns; a
72/// bitset enum would be less readable and harder to (de)serialize from
73/// `pg_publication`.
74#[allow(clippy::struct_excessive_bools)]
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76pub struct PublishKinds {
77    /// `INSERT` is replicated.
78    pub insert: bool,
79    /// `UPDATE` is replicated.
80    pub update: bool,
81    /// `DELETE` is replicated.
82    pub delete: bool,
83    /// `TRUNCATE` is replicated.
84    pub truncate: bool,
85}
86
87impl PublishKinds {
88    /// PG's `CREATE PUBLICATION` default when `publish` is unspecified:
89    /// all four DML kinds enabled.
90    #[must_use]
91    pub const fn pg_default() -> Self {
92        Self {
93            insert: true,
94            update: true,
95            delete: true,
96            truncate: true,
97        }
98    }
99
100    /// True iff at least one DML kind is enabled. An empty bitset is
101    /// illegal at the IR level (canon rejects).
102    #[must_use]
103    pub const fn is_empty(&self) -> bool {
104        !self.insert && !self.update && !self.delete && !self.truncate
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::identifier::Identifier;
112
113    fn id(s: &str) -> Identifier {
114        Identifier::from_unquoted(s).unwrap()
115    }
116
117    #[test]
118    fn publish_kinds_default_all_true() {
119        let k = PublishKinds::pg_default();
120        assert!(k.insert && k.update && k.delete && k.truncate);
121        assert!(!k.is_empty());
122    }
123
124    #[test]
125    fn publish_kinds_is_empty_when_all_false() {
126        let k = PublishKinds {
127            insert: false,
128            update: false,
129            delete: false,
130            truncate: false,
131        };
132        assert!(k.is_empty());
133    }
134
135    #[test]
136    fn scope_all_tables_does_not_equal_empty_selective() {
137        let a = PublicationScope::AllTables;
138        let b = PublicationScope::Selective {
139            schemas: BTreeSet::new(),
140            tables: Vec::new(),
141        };
142        assert_ne!(a, b);
143    }
144
145    #[test]
146    fn selective_with_a_schema_equals_itself() {
147        let s = PublicationScope::Selective {
148            schemas: BTreeSet::from([id("app")]),
149            tables: Vec::new(),
150        };
151        assert_eq!(s.clone(), s);
152    }
153}