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}