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}