use std::collections::BTreeMap;
use std::collections::BTreeSet;
use crate::diff::destructiveness::Destructiveness;
use crate::identifier::Identifier;
use crate::ir::cluster::catalog::ClusterCatalog;
use crate::ir::cluster::role::{Role, RoleAttributes};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClusterChange {
CreateRole(Role),
DropRole {
name: Identifier,
},
AlterRoleAttributes {
name: Identifier,
from: RoleAttributes,
to: RoleAttributes,
},
GrantRoleMembership {
member: Identifier,
role: Identifier,
},
RevokeRoleMembership {
member: Identifier,
role: Identifier,
},
CommentOnRole {
name: Identifier,
comment: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClusterChangeEntry {
pub change: ClusterChange,
pub destructiveness: Destructiveness,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClusterChangeSet {
pub entries: Vec<ClusterChangeEntry>,
}
impl ClusterChangeSet {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[must_use]
pub fn diff_cluster(target: &ClusterCatalog, source: &ClusterCatalog) -> ClusterChangeSet {
let mut entries = Vec::new();
let target_map: BTreeMap<&Identifier, &Role> =
target.roles.iter().map(|r| (&r.name, r)).collect();
let source_map: BTreeMap<&Identifier, &Role> =
source.roles.iter().map(|r| (&r.name, r)).collect();
for (name, source_role) in &source_map {
if !target_map.contains_key(name) {
entries.push(ClusterChangeEntry {
change: ClusterChange::CreateRole((*source_role).clone()),
destructiveness: Destructiveness::Safe,
});
}
}
for (name, target_role) in &target_map {
match source_map.get(name) {
None => entries.push(ClusterChangeEntry {
change: ClusterChange::DropRole {
name: (*name).clone(),
},
destructiveness: Destructiveness::RequiresApprovalAndDataLossWarning {
reason: format!("drops role {name} — may orphan grants in other DBs"),
},
}),
Some(source_role) => diff_role(target_role, source_role, &mut entries),
}
}
ClusterChangeSet { entries }
}
fn diff_role(target: &Role, source: &Role, out: &mut Vec<ClusterChangeEntry>) {
if target.attributes != source.attributes {
out.push(ClusterChangeEntry {
change: ClusterChange::AlterRoleAttributes {
name: target.name.clone(),
from: target.attributes.clone(),
to: source.attributes.clone(),
},
destructiveness: Destructiveness::Safe,
});
}
let target_membership: BTreeSet<&Identifier> = target.member_of.iter().collect();
let source_membership: BTreeSet<&Identifier> = source.member_of.iter().collect();
for added in source_membership.difference(&target_membership) {
out.push(ClusterChangeEntry {
change: ClusterChange::GrantRoleMembership {
member: target.name.clone(),
role: (*added).clone(),
},
destructiveness: Destructiveness::Safe,
});
}
for removed in target_membership.difference(&source_membership) {
out.push(ClusterChangeEntry {
change: ClusterChange::RevokeRoleMembership {
member: target.name.clone(),
role: (*removed).clone(),
},
destructiveness: Destructiveness::Safe,
});
}
if target.comment != source.comment {
out.push(ClusterChangeEntry {
change: ClusterChange::CommentOnRole {
name: target.name.clone(),
comment: source.comment.clone(),
},
destructiveness: Destructiveness::Safe,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn role(name: &str) -> Role {
Role {
name: id(name),
attributes: RoleAttributes::default(),
member_of: vec![],
comment: None,
}
}
#[test]
fn equal_catalogs_yield_no_changes() {
let c = ClusterCatalog {
roles: vec![role("a")],
};
let cs = diff_cluster(&c, &c);
assert!(cs.is_empty());
}
#[test]
fn added_role_creates() {
let target = ClusterCatalog::empty();
let source = ClusterCatalog {
roles: vec![role("a")],
};
let cs = diff_cluster(&target, &source);
assert_eq!(cs.entries.len(), 1);
assert!(matches!(cs.entries[0].change, ClusterChange::CreateRole(_)));
assert_eq!(cs.entries[0].destructiveness, Destructiveness::Safe);
}
#[test]
fn removed_role_drops_with_intent_gate() {
let target = ClusterCatalog {
roles: vec![role("a")],
};
let source = ClusterCatalog::empty();
let cs = diff_cluster(&target, &source);
assert_eq!(cs.entries.len(), 1);
assert!(matches!(
cs.entries[0].change,
ClusterChange::DropRole { .. }
));
assert!(cs.entries[0].destructiveness.requires_approval());
assert!(cs.entries[0].destructiveness.data_loss_risk());
}
#[test]
fn attribute_change_emits_alter() {
let t = role("a");
let mut s = role("a");
s.attributes.login = true;
let cs = diff_cluster(
&ClusterCatalog { roles: vec![t] },
&ClusterCatalog { roles: vec![s] },
);
assert_eq!(cs.entries.len(), 1);
assert!(matches!(
cs.entries[0].change,
ClusterChange::AlterRoleAttributes { .. }
));
}
#[test]
fn added_membership_emits_grant() {
let t = role("a");
let mut s = role("a");
s.member_of.push(id("readers"));
let cs = diff_cluster(
&ClusterCatalog { roles: vec![t] },
&ClusterCatalog { roles: vec![s] },
);
assert_eq!(cs.entries.len(), 1);
match &cs.entries[0].change {
ClusterChange::GrantRoleMembership { member, role } => {
assert_eq!(member.as_str(), "a");
assert_eq!(role.as_str(), "readers");
}
other => panic!("got {other:?}"),
}
}
#[test]
fn removed_membership_emits_revoke() {
let mut t = role("a");
let s = role("a");
t.member_of.push(id("readers"));
let cs = diff_cluster(
&ClusterCatalog { roles: vec![t] },
&ClusterCatalog { roles: vec![s] },
);
assert_eq!(cs.entries.len(), 1);
assert!(matches!(
cs.entries[0].change,
ClusterChange::RevokeRoleMembership { .. }
));
}
#[test]
fn comment_change_emits_comment_op() {
let t = role("a");
let mut s = role("a");
s.comment = Some("hello".into());
let cs = diff_cluster(
&ClusterCatalog { roles: vec![t] },
&ClusterCatalog { roles: vec![s] },
);
assert_eq!(cs.entries.len(), 1);
assert!(matches!(
cs.entries[0].change,
ClusterChange::CommentOnRole { .. }
));
}
}