Skip to main content

pgevolve_core/diff/
cluster.rs

1//! Cluster diffing.
2//!
3//! Pair-by-name on roles and tablespaces; emit one [`ClusterChange`] per
4//! difference. All ops are catalog-only metadata, so they're safe by default —
5//! except `DropRole` and `DropTablespace`, which are intent-gated because they
6//! can orphan objects in other parts of the cluster.
7
8use std::collections::BTreeMap;
9use std::collections::BTreeSet;
10
11use crate::diff::destructiveness::Destructiveness;
12use crate::identifier::Identifier;
13use crate::ir::cluster::catalog::ClusterCatalog;
14use crate::ir::cluster::role::{Role, RoleAttributes};
15use crate::ir::cluster::tablespace::Tablespace;
16
17/// One change to apply to a cluster's role layout.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ClusterChange {
20    /// Create a new role with the given definition.
21    CreateRole(Role),
22    /// Drop an existing role by name.
23    DropRole {
24        /// Name of the role to drop.
25        name: Identifier,
26    },
27    /// Alter the attributes of an existing role.
28    ///
29    /// Both `from` and `to` are carried so that downstream lint rules (e.g.
30    /// `role-loses-superuser`) can inspect the before state.
31    AlterRoleAttributes {
32        /// Name of the role being altered.
33        name: Identifier,
34        /// Attribute values before the change.
35        from: RoleAttributes,
36        /// Attribute values after the change.
37        to: RoleAttributes,
38    },
39    /// Grant membership of `role` to `member`.
40    GrantRoleMembership {
41        /// Who gains the membership.
42        member: Identifier,
43        /// Which role they become a member of.
44        role: Identifier,
45    },
46    /// Revoke membership of `role` from `member`.
47    RevokeRoleMembership {
48        /// Who loses the membership.
49        member: Identifier,
50        /// Which role they are removed from.
51        role: Identifier,
52    },
53    /// Set or clear the comment on a role.
54    CommentOnRole {
55        /// Name of the role.
56        name: Identifier,
57        /// New comment value; `None` clears the existing comment.
58        comment: Option<String>,
59    },
60
61    /// `CREATE TABLESPACE …`
62    CreateTablespace(Tablespace),
63    /// `DROP TABLESPACE …` — destructive.
64    DropTablespace {
65        /// Name of the tablespace to drop.
66        name: Identifier,
67    },
68    /// `ALTER TABLESPACE name OWNER TO owner;`
69    AlterTablespaceOwner {
70        /// Name of the tablespace.
71        name: Identifier,
72        /// New owner role.
73        owner: Identifier,
74    },
75    /// `ALTER TABLESPACE name SET (k = v, …);` — only source-declared options
76    /// that differ from live (lenient: live-only options are never reset).
77    SetTablespaceOptions {
78        /// Name of the tablespace.
79        name: Identifier,
80        /// Subset of options whose value differs from live.
81        options: BTreeMap<String, String>,
82    },
83    /// `COMMENT ON TABLESPACE name IS …;`
84    CommentOnTablespace {
85        /// Name of the tablespace.
86        name: Identifier,
87        /// New comment value; `None` clears the existing comment.
88        comment: Option<String>,
89    },
90}
91
92/// A single entry in a [`ClusterChangeSet`], pairing a change with its risk level.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ClusterChangeEntry {
95    /// The structural change.
96    pub change: ClusterChange,
97    /// Risk classification for the change.
98    pub destructiveness: Destructiveness,
99}
100
101/// The full set of changes needed to converge one [`ClusterCatalog`] to another.
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct ClusterChangeSet {
104    /// Ordered list of change entries.
105    pub entries: Vec<ClusterChangeEntry>,
106}
107
108impl ClusterChangeSet {
109    /// Returns `true` when no changes are present.
110    #[must_use]
111    pub const fn is_empty(&self) -> bool {
112        self.entries.is_empty()
113    }
114}
115
116/// Diff `source` against `target`. `target` = current live cluster state;
117/// `source` = desired state from `roles/*.sql`. Resulting ops applied to
118/// `target` produce `source`.
119#[must_use]
120pub fn diff_cluster(target: &ClusterCatalog, source: &ClusterCatalog) -> ClusterChangeSet {
121    let mut entries = Vec::new();
122
123    let target_map: BTreeMap<&Identifier, &Role> =
124        target.roles.iter().map(|r| (&r.name, r)).collect();
125    let source_map: BTreeMap<&Identifier, &Role> =
126        source.roles.iter().map(|r| (&r.name, r)).collect();
127
128    // Adds.
129    for (name, source_role) in &source_map {
130        if !target_map.contains_key(name) {
131            entries.push(ClusterChangeEntry {
132                change: ClusterChange::CreateRole((*source_role).clone()),
133                destructiveness: Destructiveness::Safe,
134            });
135        }
136    }
137
138    // Drops + alters.
139    for (name, target_role) in &target_map {
140        match source_map.get(name) {
141            None => entries.push(ClusterChangeEntry {
142                change: ClusterChange::DropRole {
143                    name: (*name).clone(),
144                },
145                destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
146                    reason: format!("drops role {name} — may orphan grants in other DBs"),
147                },
148            }),
149            Some(source_role) => diff_role(target_role, source_role, &mut entries),
150        }
151    }
152
153    // Tablespaces. Lenient owner/options; location is immutable in PG
154    // (drift → tablespace-location-drift lint, not a change).
155    let target_ts: BTreeMap<&Identifier, &Tablespace> =
156        target.tablespaces.iter().map(|t| (&t.name, t)).collect();
157    let source_ts: BTreeMap<&Identifier, &Tablespace> =
158        source.tablespaces.iter().map(|t| (&t.name, t)).collect();
159
160    for (name, s) in &source_ts {
161        match target_ts.get(name) {
162            None => entries.push(ClusterChangeEntry {
163                change: ClusterChange::CreateTablespace((*s).clone()),
164                destructiveness: Destructiveness::Safe,
165            }),
166            Some(t) => {
167                // Owner (lenient): only emit if source declares an owner and it
168                // differs from live.
169                if let Some(src_owner) = &s.owner
170                    && t.owner.as_ref() != Some(src_owner)
171                {
172                    entries.push(ClusterChangeEntry {
173                        change: ClusterChange::AlterTablespaceOwner {
174                            name: (*name).clone(),
175                            owner: src_owner.clone(),
176                        },
177                        destructiveness: Destructiveness::Safe,
178                    });
179                }
180
181                // Options (lenient): emit only source options whose value
182                // differs from live; never reset live-only options.
183                let mut set: BTreeMap<String, String> = BTreeMap::new();
184                for (k, v) in &s.options {
185                    if t.options.get(k) != Some(v) {
186                        set.insert(k.clone(), v.clone());
187                    }
188                }
189                if !set.is_empty() {
190                    entries.push(ClusterChangeEntry {
191                        change: ClusterChange::SetTablespaceOptions {
192                            name: (*name).clone(),
193                            options: set,
194                        },
195                        destructiveness: Destructiveness::Safe,
196                    });
197                }
198
199                // Comment.
200                if t.comment != s.comment {
201                    entries.push(ClusterChangeEntry {
202                        change: ClusterChange::CommentOnTablespace {
203                            name: (*name).clone(),
204                            comment: s.comment.clone(),
205                        },
206                        destructiveness: Destructiveness::Safe,
207                    });
208                }
209
210                // Location: immutable in PG — drift is handled by the
211                // tablespace-location-drift lint, NOT a change here.
212            }
213        }
214    }
215
216    for name in target_ts.keys() {
217        if !source_ts.contains_key(name) {
218            entries.push(ClusterChangeEntry {
219                change: ClusterChange::DropTablespace {
220                    name: (*name).clone(),
221                },
222                destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
223                    reason: format!("drops tablespace {name} — objects using it will fail"),
224                },
225            });
226        }
227    }
228
229    ClusterChangeSet { entries }
230}
231
232fn diff_role(target: &Role, source: &Role, out: &mut Vec<ClusterChangeEntry>) {
233    if target.attributes != source.attributes {
234        out.push(ClusterChangeEntry {
235            change: ClusterChange::AlterRoleAttributes {
236                name: target.name.clone(),
237                from: target.attributes.clone(),
238                to: source.attributes.clone(),
239            },
240            destructiveness: Destructiveness::Safe,
241        });
242    }
243
244    // Membership: emit one Grant per added edge, one Revoke per removed.
245    let target_membership: BTreeSet<&Identifier> = target.member_of.iter().collect();
246    let source_membership: BTreeSet<&Identifier> = source.member_of.iter().collect();
247
248    for added in source_membership.difference(&target_membership) {
249        out.push(ClusterChangeEntry {
250            change: ClusterChange::GrantRoleMembership {
251                member: target.name.clone(),
252                role: (*added).clone(),
253            },
254            destructiveness: Destructiveness::Safe,
255        });
256    }
257    for removed in target_membership.difference(&source_membership) {
258        out.push(ClusterChangeEntry {
259            change: ClusterChange::RevokeRoleMembership {
260                member: target.name.clone(),
261                role: (*removed).clone(),
262            },
263            destructiveness: Destructiveness::Safe,
264        });
265    }
266
267    if target.comment != source.comment {
268        out.push(ClusterChangeEntry {
269            change: ClusterChange::CommentOnRole {
270                name: target.name.clone(),
271                comment: source.comment.clone(),
272            },
273            destructiveness: Destructiveness::Safe,
274        });
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn id(s: &str) -> Identifier {
283        Identifier::from_unquoted(s).unwrap()
284    }
285
286    fn role(name: &str) -> Role {
287        Role {
288            name: id(name),
289            attributes: RoleAttributes::default(),
290            member_of: vec![],
291            comment: None,
292        }
293    }
294
295    #[test]
296    fn equal_catalogs_yield_no_changes() {
297        let c = ClusterCatalog {
298            roles: vec![role("a")],
299            tablespaces: vec![],
300        };
301        let cs = diff_cluster(&c, &c);
302        assert!(cs.is_empty());
303    }
304
305    #[test]
306    fn added_role_creates() {
307        let target = ClusterCatalog::empty();
308        let source = ClusterCatalog {
309            roles: vec![role("a")],
310            tablespaces: vec![],
311        };
312        let cs = diff_cluster(&target, &source);
313        assert_eq!(cs.entries.len(), 1);
314        assert!(matches!(cs.entries[0].change, ClusterChange::CreateRole(_)));
315        assert_eq!(cs.entries[0].destructiveness, Destructiveness::Safe);
316    }
317
318    #[test]
319    fn removed_role_drops_with_intent_gate() {
320        let target = ClusterCatalog {
321            roles: vec![role("a")],
322            tablespaces: vec![],
323        };
324        let source = ClusterCatalog::empty();
325        let cs = diff_cluster(&target, &source);
326        assert_eq!(cs.entries.len(), 1);
327        assert!(matches!(
328            cs.entries[0].change,
329            ClusterChange::DropRole { .. }
330        ));
331        assert!(cs.entries[0].destructiveness.requires_approval());
332        assert!(cs.entries[0].destructiveness.data_loss_risk());
333    }
334
335    #[test]
336    fn attribute_change_emits_alter() {
337        let t = role("a");
338        let mut s = role("a");
339        s.attributes.login = true;
340        let cs = diff_cluster(
341            &ClusterCatalog {
342                roles: vec![t],
343                tablespaces: vec![],
344            },
345            &ClusterCatalog {
346                roles: vec![s],
347                tablespaces: vec![],
348            },
349        );
350        assert_eq!(cs.entries.len(), 1);
351        assert!(matches!(
352            cs.entries[0].change,
353            ClusterChange::AlterRoleAttributes { .. }
354        ));
355    }
356
357    #[test]
358    fn added_membership_emits_grant() {
359        let t = role("a");
360        let mut s = role("a");
361        s.member_of.push(id("readers"));
362        let cs = diff_cluster(
363            &ClusterCatalog {
364                roles: vec![t],
365                tablespaces: vec![],
366            },
367            &ClusterCatalog {
368                roles: vec![s],
369                tablespaces: vec![],
370            },
371        );
372        assert_eq!(cs.entries.len(), 1);
373        match &cs.entries[0].change {
374            ClusterChange::GrantRoleMembership { member, role } => {
375                assert_eq!(member.as_str(), "a");
376                assert_eq!(role.as_str(), "readers");
377            }
378            other => panic!("got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn removed_membership_emits_revoke() {
384        let mut t = role("a");
385        let s = role("a");
386        t.member_of.push(id("readers"));
387        let cs = diff_cluster(
388            &ClusterCatalog {
389                roles: vec![t],
390                tablespaces: vec![],
391            },
392            &ClusterCatalog {
393                roles: vec![s],
394                tablespaces: vec![],
395            },
396        );
397        assert_eq!(cs.entries.len(), 1);
398        assert!(matches!(
399            cs.entries[0].change,
400            ClusterChange::RevokeRoleMembership { .. }
401        ));
402    }
403
404    #[test]
405    fn comment_change_emits_comment_op() {
406        let t = role("a");
407        let mut s = role("a");
408        s.comment = Some("hello".into());
409        let cs = diff_cluster(
410            &ClusterCatalog {
411                roles: vec![t],
412                tablespaces: vec![],
413            },
414            &ClusterCatalog {
415                roles: vec![s],
416                tablespaces: vec![],
417            },
418        );
419        assert_eq!(cs.entries.len(), 1);
420        assert!(matches!(
421            cs.entries[0].change,
422            ClusterChange::CommentOnRole { .. }
423        ));
424    }
425
426    // -----------------------------------------------------------------------
427    // Tablespace tests
428    // -----------------------------------------------------------------------
429
430    fn ts(name: &str) -> Tablespace {
431        Tablespace {
432            name: id(name),
433            location: "/mnt/ssd".to_string(),
434            owner: None,
435            options: BTreeMap::new(),
436            comment: None,
437        }
438    }
439
440    #[test]
441    fn source_only_tablespace_creates() {
442        let target = ClusterCatalog::empty();
443        let source = ClusterCatalog {
444            roles: vec![],
445            tablespaces: vec![ts("fast")],
446        };
447        let cs = diff_cluster(&target, &source);
448        assert_eq!(cs.entries.len(), 1);
449        assert!(matches!(
450            cs.entries[0].change,
451            ClusterChange::CreateTablespace(_)
452        ));
453        assert_eq!(cs.entries[0].destructiveness, Destructiveness::Safe);
454    }
455
456    #[test]
457    fn live_only_tablespace_drops_with_intent_gate() {
458        let target = ClusterCatalog {
459            roles: vec![],
460            tablespaces: vec![ts("fast")],
461        };
462        let source = ClusterCatalog::empty();
463        let cs = diff_cluster(&target, &source);
464        assert_eq!(cs.entries.len(), 1);
465        assert!(matches!(
466            cs.entries[0].change,
467            ClusterChange::DropTablespace { .. }
468        ));
469        assert!(cs.entries[0].destructiveness.requires_approval());
470        assert!(cs.entries[0].destructiveness.data_loss_risk());
471    }
472
473    #[test]
474    fn owner_change_emits_alter_owner() {
475        let t = ts("fast");
476        let mut s = ts("fast");
477        s.owner = Some(id("dba"));
478        let cs = diff_cluster(
479            &ClusterCatalog {
480                roles: vec![],
481                tablespaces: vec![t],
482            },
483            &ClusterCatalog {
484                roles: vec![],
485                tablespaces: vec![s],
486            },
487        );
488        assert_eq!(cs.entries.len(), 1);
489        assert!(matches!(
490            cs.entries[0].change,
491            ClusterChange::AlterTablespaceOwner { .. }
492        ));
493        assert_eq!(cs.entries[0].destructiveness, Destructiveness::Safe);
494    }
495
496    #[test]
497    fn source_owner_none_emits_nothing_even_if_live_has_owner() {
498        // Lenient: source owner = None means "unmanaged" — do not emit an alter.
499        let mut t = ts("fast");
500        t.owner = Some(id("postgres"));
501        let s = ts("fast"); // owner = None
502        let cs = diff_cluster(
503            &ClusterCatalog {
504                roles: vec![],
505                tablespaces: vec![t],
506            },
507            &ClusterCatalog {
508                roles: vec![],
509                tablespaces: vec![s],
510            },
511        );
512        assert!(cs.is_empty());
513    }
514
515    #[test]
516    fn options_diff_emits_only_source_options() {
517        // source has {a:1, b:2}, live has {a:1, c:9}
518        // → emit SetTablespaceOptions for {b:2} only; live-only c is NOT reset.
519        let mut t = ts("fast");
520        t.options.insert("a".to_string(), "1".to_string());
521        t.options.insert("c".to_string(), "9".to_string());
522        let mut s = ts("fast");
523        s.options.insert("a".to_string(), "1".to_string());
524        s.options.insert("b".to_string(), "2".to_string());
525        let cs = diff_cluster(
526            &ClusterCatalog {
527                roles: vec![],
528                tablespaces: vec![t],
529            },
530            &ClusterCatalog {
531                roles: vec![],
532                tablespaces: vec![s],
533            },
534        );
535        assert_eq!(cs.entries.len(), 1);
536        match &cs.entries[0].change {
537            ClusterChange::SetTablespaceOptions { name, options } => {
538                assert_eq!(name.as_str(), "fast");
539                assert_eq!(options.len(), 1);
540                assert_eq!(options.get("b").map(String::as_str), Some("2"));
541            }
542            other => panic!("expected SetTablespaceOptions, got {other:?}"),
543        }
544    }
545
546    #[test]
547    fn comment_change_emits_comment_on_tablespace() {
548        let t = ts("fast");
549        let mut s = ts("fast");
550        s.comment = Some("fast storage".to_string());
551        let cs = diff_cluster(
552            &ClusterCatalog {
553                roles: vec![],
554                tablespaces: vec![t],
555            },
556            &ClusterCatalog {
557                roles: vec![],
558                tablespaces: vec![s],
559            },
560        );
561        assert_eq!(cs.entries.len(), 1);
562        assert!(matches!(
563            cs.entries[0].change,
564            ClusterChange::CommentOnTablespace { .. }
565        ));
566    }
567
568    #[test]
569    fn location_change_emits_nothing() {
570        // PG cannot relocate a tablespace; drift is handled by the
571        // tablespace-location-drift lint, not a ClusterChange.
572        let t = ts("fast"); // location = "/mnt/ssd"
573        let mut s = ts("fast");
574        s.location = "/mnt/nvme".to_string();
575        let cs = diff_cluster(
576            &ClusterCatalog {
577                roles: vec![],
578                tablespaces: vec![t],
579            },
580            &ClusterCatalog {
581                roles: vec![],
582                tablespaces: vec![s],
583            },
584        );
585        assert!(
586            cs.is_empty(),
587            "location drift must not produce a change; got {cs:?}"
588        );
589    }
590}