1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ClusterChange {
20 CreateRole(Role),
22 DropRole {
24 name: Identifier,
26 },
27 AlterRoleAttributes {
32 name: Identifier,
34 from: RoleAttributes,
36 to: RoleAttributes,
38 },
39 GrantRoleMembership {
41 member: Identifier,
43 role: Identifier,
45 },
46 RevokeRoleMembership {
48 member: Identifier,
50 role: Identifier,
52 },
53 CommentOnRole {
55 name: Identifier,
57 comment: Option<String>,
59 },
60
61 CreateTablespace(Tablespace),
63 DropTablespace {
65 name: Identifier,
67 },
68 AlterTablespaceOwner {
70 name: Identifier,
72 owner: Identifier,
74 },
75 SetTablespaceOptions {
78 name: Identifier,
80 options: BTreeMap<String, String>,
82 },
83 CommentOnTablespace {
85 name: Identifier,
87 comment: Option<String>,
89 },
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ClusterChangeEntry {
95 pub change: ClusterChange,
97 pub destructiveness: Destructiveness,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct ClusterChangeSet {
104 pub entries: Vec<ClusterChangeEntry>,
106}
107
108impl ClusterChangeSet {
109 #[must_use]
111 pub const fn is_empty(&self) -> bool {
112 self.entries.is_empty()
113 }
114}
115
116#[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 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 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 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 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 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 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 }
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 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 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 let mut t = ts("fast");
500 t.owner = Some(id("postgres"));
501 let s = ts("fast"); 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 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 let t = ts("fast"); 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}