Skip to main content

pgevolve_core/diff/
publications.rs

1//! Differ for publications. Pair by name; per-publication granular diff.
2//!
3//! Key behaviors:
4//! - Source has it, target doesn't → `CreatePublication` (Safe).
5//! - Target has it, source doesn't → no auto-drop (lenient); surfaces via
6//!   `unmanaged-publication` lint in Stage 9.
7//! - Both have it, mode mismatch (`AllTables` ↔ `Selective`) → `ReplacePublication`
8//!   (`RequiresApproval`). No per-field diffs — replace handles everything.
9//! - Both have it, same `Selective` mode → per-table add/drop/set, per-schema
10//!   add/drop, per-publication scalar diffs, owner (v0.3.1 lenient pattern).
11//!
12//! Spec: `docs/superpowers/specs/2026-05-26-publications-design.md`.
13
14use std::collections::BTreeMap;
15
16use crate::diff::change::Change;
17use crate::diff::changeset::ChangeSet;
18use crate::diff::destructiveness::Destructiveness;
19use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
20use crate::identifier::Identifier;
21use crate::ir::catalog::Catalog;
22use crate::ir::publication::{Publication, PublicationScope, PublishedTable};
23
24/// Compute granular publication changes needed to converge `target` toward
25/// `source`. Appends all emitted changes to `out`.
26pub fn diff_publications(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
27    let target_map: BTreeMap<&Identifier, &Publication> =
28        target.publications.iter().map(|p| (&p.name, p)).collect();
29    let source_map: BTreeMap<&Identifier, &Publication> =
30        source.publications.iter().map(|p| (&p.name, p)).collect();
31
32    // Creates: in source but not in target.
33    for (name, src) in &source_map {
34        if !target_map.contains_key(name) {
35            out.push(
36                Change::CreatePublication((*src).clone()),
37                Destructiveness::Safe,
38            );
39        }
40    }
41
42    // Target-only: lenient — no auto-drop. Surfaces via unmanaged-publication lint.
43    // Intentionally no-op loop; Stage 9 adds the unmanaged-publication lint rule.
44    for _name in target_map.keys() {
45        // unmanaged-publication lint (Stage 9) handles publications absent from source.
46    }
47
48    // Modifies: in both.
49    for (name, src) in &source_map {
50        let Some(tgt) = target_map.get(name) else {
51            continue;
52        };
53        diff_one_publication(tgt, src, out);
54    }
55}
56
57fn diff_one_publication(target: &Publication, source: &Publication, out: &mut ChangeSet) {
58    // Mode mismatch → ReplacePublication (RequiresApproval).
59    // A mode swap stops replication for the old set of tables, so it needs
60    // explicit approval. No data is destroyed (WAL is not deleted), but
61    // subscribers will see an interruption.
62    let target_mode = std::mem::discriminant(&target.scope);
63    let source_mode = std::mem::discriminant(&source.scope);
64    if target_mode != source_mode {
65        out.push(
66            Change::ReplacePublication {
67                from: target.clone(),
68                to: source.clone(),
69            },
70            Destructiveness::RequiresApproval {
71                reason: format!(
72                    "publication {} mode swap (AllTables ↔ Selective)",
73                    source.name
74                ),
75            },
76        );
77        // Do not emit per-field diffs — the replace handles everything.
78        return;
79    }
80
81    // Same mode. For Selective, diff tables and schemas granularly.
82    if let (
83        PublicationScope::Selective {
84            schemas: t_schemas,
85            tables: t_tables,
86        },
87        PublicationScope::Selective {
88            schemas: s_schemas,
89            tables: s_tables,
90        },
91    ) = (&target.scope, &source.scope)
92    {
93        diff_selective_tables(&source.name, t_tables, s_tables, out);
94        diff_selective_schemas(&source.name, t_schemas, s_schemas, out);
95    }
96    // AllTables mode has no per-table or per-schema granular diffs.
97
98    // Per-publication scalar diffs.
99    if target.publish != source.publish {
100        out.push(
101            Change::AlterPublicationSetPublish {
102                publication: source.name.clone(),
103                kinds: source.publish,
104            },
105            Destructiveness::Safe,
106        );
107    }
108    if target.publish_via_partition_root != source.publish_via_partition_root {
109        out.push(
110            Change::AlterPublicationSetViaRoot {
111                publication: source.name.clone(),
112                value: source.publish_via_partition_root,
113            },
114            Destructiveness::Safe,
115        );
116    }
117    if target.comment != source.comment {
118        out.push(
119            Change::CommentOnPublication {
120                name: source.name.clone(),
121                comment: source.comment.clone(),
122            },
123            Destructiveness::Safe,
124        );
125    }
126
127    // Owner: v0.3.1 lenient pattern — only emit when source declares an owner
128    // and it differs from target. Source `None` = unmanaged, no change emitted.
129    if let Some(s_owner) = &source.owner
130        && target.owner.as_ref() != Some(s_owner)
131    {
132        let from = target.owner.clone().unwrap_or_else(|| {
133            Identifier::from_unquoted("__unknown_owner__")
134                .expect("literal is always a valid unquoted identifier")
135        });
136        out.push(
137            Change::AlterObjectOwner(AlterObjectOwner {
138                kind: OwnerObjectKind::Publication,
139                // Publications are not schema-qualified. We use a synthetic
140                // QualifiedName with `__cluster__` as the schema component to
141                // satisfy the `QualifiedName` type (same convention as cluster
142                // ops in plan/cluster_rewrite/emit.rs).
143                qname: crate::identifier::QualifiedName::new(
144                    Identifier::from_unquoted("__cluster__")
145                        .expect("literal is always a valid unquoted identifier"),
146                    source.name.clone(),
147                ),
148                signature: String::new(),
149                from,
150                to: s_owner.clone(),
151            }),
152            Destructiveness::Safe,
153        );
154    }
155}
156
157fn diff_selective_tables(
158    pub_name: &Identifier,
159    target_tables: &[PublishedTable],
160    source_tables: &[PublishedTable],
161    out: &mut ChangeSet,
162) {
163    let t_map: BTreeMap<_, _> = target_tables.iter().map(|t| (&t.qname, t)).collect();
164    let s_map: BTreeMap<_, _> = source_tables.iter().map(|t| (&t.qname, t)).collect();
165
166    // Added tables: in source but not in target.
167    for (qname, t) in &s_map {
168        if !t_map.contains_key(qname) {
169            out.push(
170                Change::AlterPublicationAddTable {
171                    publication: pub_name.clone(),
172                    table: (*t).clone(),
173                },
174                Destructiveness::Safe,
175            );
176        }
177    }
178
179    // Dropped tables: in target but not in source.
180    for qname in t_map.keys() {
181        if !s_map.contains_key(qname) {
182            out.push(
183                Change::AlterPublicationDropTable {
184                    publication: pub_name.clone(),
185                    qname: (*qname).clone(),
186                },
187                Destructiveness::Safe,
188            );
189        }
190    }
191
192    // Changed tables: in both, but row_filter or columns differ.
193    for (qname, src_table) in &s_map {
194        let Some(tgt_table) = t_map.get(qname) else {
195            continue;
196        };
197        if tgt_table.row_filter != src_table.row_filter || tgt_table.columns != src_table.columns {
198            out.push(
199                Change::AlterPublicationSetTable {
200                    publication: pub_name.clone(),
201                    table: (*src_table).clone(),
202                },
203                Destructiveness::Safe,
204            );
205        }
206    }
207}
208
209fn diff_selective_schemas(
210    pub_name: &Identifier,
211    target_schemas: &std::collections::BTreeSet<Identifier>,
212    source_schemas: &std::collections::BTreeSet<Identifier>,
213    out: &mut ChangeSet,
214) {
215    // Added schemas: in source but not in target.
216    for s in source_schemas.difference(target_schemas) {
217        out.push(
218            Change::AlterPublicationAddSchema {
219                publication: pub_name.clone(),
220                schema: s.clone(),
221            },
222            Destructiveness::Safe,
223        );
224    }
225
226    // Dropped schemas: in target but not in source.
227    for s in target_schemas.difference(source_schemas) {
228        out.push(
229            Change::AlterPublicationDropSchema {
230                publication: pub_name.clone(),
231                schema: s.clone(),
232            },
233            Destructiveness::Safe,
234        );
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::identifier::{Identifier, QualifiedName};
242    use crate::ir::catalog::Catalog;
243    use crate::ir::publication::{Publication, PublicationScope, PublishKinds, PublishedTable};
244    use std::collections::BTreeSet;
245
246    fn id(s: &str) -> Identifier {
247        Identifier::from_unquoted(s).unwrap()
248    }
249
250    fn qn(schema: &str, name: &str) -> QualifiedName {
251        QualifiedName::new(id(schema), id(name))
252    }
253
254    fn pub_all_tables(name: &str) -> Publication {
255        Publication {
256            name: id(name),
257            scope: PublicationScope::AllTables,
258            publish: PublishKinds::pg_default(),
259            publish_via_partition_root: false,
260            owner: None,
261            comment: None,
262        }
263    }
264
265    fn pub_selective(name: &str, tables: Vec<PublishedTable>) -> Publication {
266        Publication {
267            name: id(name),
268            scope: PublicationScope::Selective {
269                schemas: BTreeSet::new(),
270                tables,
271            },
272            publish: PublishKinds::pg_default(),
273            publish_via_partition_root: false,
274            owner: None,
275            comment: None,
276        }
277    }
278
279    fn table_entry(schema: &str, name: &str) -> PublishedTable {
280        PublishedTable {
281            qname: qn(schema, name),
282            row_filter: None,
283            columns: None,
284        }
285    }
286
287    fn catalog_with(pubs: Vec<Publication>) -> Catalog {
288        let mut c = Catalog::empty();
289        c.publications = pubs;
290        c
291    }
292
293    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
294        let mut out = ChangeSet::new();
295        diff_publications(target, source, &mut out);
296        out
297    }
298
299    // ---- creates ----
300
301    #[test]
302    fn create_pub_when_source_has_it_and_target_doesnt() {
303        let target = Catalog::empty();
304        let source = catalog_with(vec![pub_all_tables("p")]);
305        let changes = run_diff(&target, &source);
306        assert_eq!(changes.len(), 1);
307        assert!(matches!(
308            changes.iter().next().unwrap().change,
309            Change::CreatePublication(_)
310        ));
311    }
312
313    // ---- lenient: no auto-drop ----
314
315    #[test]
316    fn no_drop_when_target_has_pub_but_source_doesnt() {
317        let target = catalog_with(vec![pub_all_tables("p")]);
318        let source = Catalog::empty();
319        let changes = run_diff(&target, &source);
320        assert!(
321            changes.is_empty(),
322            "expected no changes (lenient), got {changes:?}"
323        );
324    }
325
326    // ---- mode mismatch ----
327
328    #[test]
329    fn mode_mismatch_emits_replace_publication() {
330        let target = catalog_with(vec![pub_all_tables("p")]);
331        let source = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
332        let changes = run_diff(&target, &source);
333        assert_eq!(changes.len(), 1);
334        let entry = changes.iter().next().unwrap();
335        assert!(
336            matches!(entry.change, Change::ReplacePublication { .. }),
337            "expected ReplacePublication, got {:?}",
338            entry.change
339        );
340        assert!(
341            entry.destructiveness.requires_approval(),
342            "mode swap must be RequiresApproval"
343        );
344    }
345
346    #[test]
347    fn mode_mismatch_emits_no_per_field_diffs() {
348        // Even if publish differs, only ReplacePublication is emitted on mode mismatch.
349        let target = catalog_with(vec![pub_all_tables("p")]);
350        let mut src_pub = pub_selective("p", vec![table_entry("app", "t")]);
351        src_pub.publish = PublishKinds {
352            insert: true,
353            update: false,
354            delete: false,
355            truncate: false,
356        };
357        let source = catalog_with(vec![src_pub]);
358        let changes = run_diff(&target, &source);
359        assert_eq!(changes.len(), 1, "only ReplacePublication, no scalar diffs");
360    }
361
362    // ---- same-Selective: per-table diffs ----
363
364    #[test]
365    fn add_table_when_source_has_it_and_target_doesnt() {
366        let target = catalog_with(vec![pub_selective("p", vec![])]);
367        let source = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
368        let changes = run_diff(&target, &source);
369        assert_eq!(changes.len(), 1);
370        assert!(matches!(
371            changes.iter().next().unwrap().change,
372            Change::AlterPublicationAddTable { .. }
373        ));
374    }
375
376    #[test]
377    fn drop_table_when_target_has_it_and_source_doesnt() {
378        let target = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
379        let source = catalog_with(vec![pub_selective("p", vec![])]);
380        let changes = run_diff(&target, &source);
381        assert_eq!(changes.len(), 1);
382        assert!(matches!(
383            changes.iter().next().unwrap().change,
384            Change::AlterPublicationDropTable { .. }
385        ));
386    }
387
388    #[test]
389    fn set_table_when_columns_differ() {
390        let mut src_table = table_entry("app", "t");
391        src_table.columns = Some(vec![id("id"), id("name")]);
392        let target = catalog_with(vec![pub_selective("p", vec![table_entry("app", "t")])]);
393        let source = catalog_with(vec![pub_selective("p", vec![src_table])]);
394        let changes = run_diff(&target, &source);
395        assert_eq!(changes.len(), 1);
396        assert!(matches!(
397            changes.iter().next().unwrap().change,
398            Change::AlterPublicationSetTable { .. }
399        ));
400    }
401
402    #[test]
403    fn no_change_when_table_identical() {
404        let t = table_entry("app", "orders");
405        let target = catalog_with(vec![pub_selective("p", vec![t.clone()])]);
406        let source = catalog_with(vec![pub_selective("p", vec![t])]);
407        let changes = run_diff(&target, &source);
408        assert!(changes.is_empty());
409    }
410
411    // ---- per-schema diffs ----
412
413    #[test]
414    fn add_schema_when_source_has_it_and_target_doesnt() {
415        let tgt_pub = Publication {
416            name: id("p"),
417            scope: PublicationScope::Selective {
418                schemas: BTreeSet::new(),
419                tables: vec![table_entry("app", "t")],
420            },
421            publish: PublishKinds::pg_default(),
422            publish_via_partition_root: false,
423            owner: None,
424            comment: None,
425        };
426        let src_pub = Publication {
427            name: id("p"),
428            scope: PublicationScope::Selective {
429                schemas: BTreeSet::from([id("app")]),
430                tables: vec![table_entry("app", "t")],
431            },
432            publish: PublishKinds::pg_default(),
433            publish_via_partition_root: false,
434            owner: None,
435            comment: None,
436        };
437        let target = catalog_with(vec![tgt_pub]);
438        let source = catalog_with(vec![src_pub]);
439        let changes = run_diff(&target, &source);
440        assert_eq!(changes.len(), 1);
441        assert!(matches!(
442            changes.iter().next().unwrap().change,
443            Change::AlterPublicationAddSchema { .. }
444        ));
445    }
446
447    #[test]
448    fn drop_schema_when_target_has_it_and_source_doesnt() {
449        let tgt_pub = Publication {
450            name: id("p"),
451            scope: PublicationScope::Selective {
452                schemas: BTreeSet::from([id("app")]),
453                tables: vec![table_entry("app", "t")],
454            },
455            publish: PublishKinds::pg_default(),
456            publish_via_partition_root: false,
457            owner: None,
458            comment: None,
459        };
460        let src_pub = Publication {
461            name: id("p"),
462            scope: PublicationScope::Selective {
463                schemas: BTreeSet::new(),
464                tables: vec![table_entry("app", "t")],
465            },
466            publish: PublishKinds::pg_default(),
467            publish_via_partition_root: false,
468            owner: None,
469            comment: None,
470        };
471        let target = catalog_with(vec![tgt_pub]);
472        let source = catalog_with(vec![src_pub]);
473        let changes = run_diff(&target, &source);
474        assert_eq!(changes.len(), 1);
475        assert!(matches!(
476            changes.iter().next().unwrap().change,
477            Change::AlterPublicationDropSchema { .. }
478        ));
479    }
480
481    // ---- scalar diffs ----
482
483    #[test]
484    fn set_publish_when_publish_kinds_differ() {
485        let target = catalog_with(vec![pub_all_tables("p")]);
486        let mut src_pub = pub_all_tables("p");
487        src_pub.publish = PublishKinds {
488            insert: true,
489            update: false,
490            delete: false,
491            truncate: false,
492        };
493        let source = catalog_with(vec![src_pub]);
494        let changes = run_diff(&target, &source);
495        assert_eq!(changes.len(), 1);
496        assert!(matches!(
497            changes.iter().next().unwrap().change,
498            Change::AlterPublicationSetPublish { .. }
499        ));
500    }
501
502    #[test]
503    fn set_via_root_when_differs() {
504        let target = catalog_with(vec![pub_all_tables("p")]);
505        let mut src_pub = pub_all_tables("p");
506        src_pub.publish_via_partition_root = true;
507        let source = catalog_with(vec![src_pub]);
508        let changes = run_diff(&target, &source);
509        assert_eq!(changes.len(), 1);
510        assert!(matches!(
511            changes.iter().next().unwrap().change,
512            Change::AlterPublicationSetViaRoot { value: true, .. }
513        ));
514    }
515
516    #[test]
517    fn comment_on_publication_when_comment_differs() {
518        let target = catalog_with(vec![pub_all_tables("p")]);
519        let mut src_pub = pub_all_tables("p");
520        src_pub.comment = Some("my pub".into());
521        let source = catalog_with(vec![src_pub]);
522        let changes = run_diff(&target, &source);
523        assert_eq!(changes.len(), 1);
524        assert!(matches!(
525            changes.iter().next().unwrap().change,
526            Change::CommentOnPublication { .. }
527        ));
528    }
529
530    // ---- owner: lenient pattern ----
531
532    #[test]
533    fn owner_change_emits_alter_object_owner() {
534        let mut tgt_pub = pub_all_tables("p");
535        tgt_pub.owner = Some(id("alice"));
536        let mut src_pub = pub_all_tables("p");
537        src_pub.owner = Some(id("bob"));
538        let target = catalog_with(vec![tgt_pub]);
539        let source = catalog_with(vec![src_pub]);
540        let changes = run_diff(&target, &source);
541        assert_eq!(changes.len(), 1);
542        assert!(matches!(
543            changes.iter().next().unwrap().change,
544            Change::AlterObjectOwner(_)
545        ));
546    }
547
548    #[test]
549    fn no_owner_change_when_source_owner_is_none() {
550        // Source `None` = unmanaged ownership; no change emitted.
551        let mut tgt_pub = pub_all_tables("p");
552        tgt_pub.owner = Some(id("alice"));
553        let src_pub = pub_all_tables("p"); // owner = None
554        let target = catalog_with(vec![tgt_pub]);
555        let source = catalog_with(vec![src_pub]);
556        let changes = run_diff(&target, &source);
557        assert!(
558            changes.is_empty(),
559            "source owner None = unmanaged, no change expected"
560        );
561    }
562
563    // ---- identity (diff against self) ----
564
565    #[test]
566    fn diff_against_self_is_empty_all_tables() {
567        let c = catalog_with(vec![pub_all_tables("p")]);
568        let changes = run_diff(&c, &c);
569        assert!(changes.is_empty());
570    }
571
572    #[test]
573    fn diff_against_self_is_empty_selective() {
574        let c = catalog_with(vec![pub_selective("p", vec![table_entry("app", "users")])]);
575        let changes = run_diff(&c, &c);
576        assert!(changes.is_empty());
577    }
578}