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