Skip to main content

pgevolve_core/diff/
changeset.rs

1//! `ChangeSet` — an *unordered* collection of [`ChangeEntry`] values, plus
2//! two observation accumulators for downstream lint rules.
3//!
4//! Ordering and dependency analysis are the planner's job (phase 5); the differ
5//! emits changes in whatever order is convenient.
6
7use serde::{Deserialize, Serialize};
8
9use crate::identifier::Identifier;
10use crate::ir::grant::GrantTarget;
11
12use super::change::{Change, ChangeEntry};
13use super::destructiveness::Destructiveness;
14
15/// A grant that exists in the live catalog but whose grantee role is not
16/// mentioned anywhere in the source catalog (unmanaged role).
17///
18/// The differ does not emit a REVOKE for unmanaged grantees (lenient policy);
19/// it surfaces them here so Stage 11 lint rules can flag them.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct UnmanagedGrantObservation {
22    /// Human-readable label for the object (e.g., `"schema app"`).
23    pub object_label: String,
24    /// SQL privilege keyword (e.g., `"SELECT"`).
25    pub privilege_label: String,
26    /// The unmanaged role name.
27    pub role_name: Identifier,
28}
29
30/// A REVOKE step that is being emitted against a role that is the same as the
31/// object's owner.
32///
33/// Revoking from the owner is typically a no-op in Postgres (owners always
34/// have implicit privileges). Stage 11 lint rules can warn about this pattern.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct RevokeWithOwnerObservation {
37    /// Human-readable label for the object (e.g., `"table app.users"`).
38    pub object_label: String,
39    /// SQL privilege keyword (e.g., `"SELECT"`).
40    pub privilege_label: String,
41    /// The grantee being revoked.
42    pub grantee: GrantTarget,
43    /// The declared owner of the object.
44    pub owner: Identifier,
45}
46
47/// An unordered set of [`ChangeEntry`] values.
48#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
49pub struct ChangeSet {
50    /// The change entries.
51    pub entries: Vec<ChangeEntry>,
52    /// Grants observed in the live catalog whose grantee role is not tracked
53    /// in the source catalog (lenient-policy observations).
54    ///
55    /// Stage 11 lint rules read this to produce `grants-to-unmanaged-role`
56    /// warnings.
57    #[serde(default)]
58    pub unmanaged_grants: Vec<UnmanagedGrantObservation>,
59    /// REVOKE steps being emitted against the declared owner of the object.
60    ///
61    /// Stage 11 lint rules read this to produce `revoke-from-owner` warnings.
62    #[serde(default)]
63    pub revokes_with_owner: Vec<RevokeWithOwnerObservation>,
64}
65
66impl ChangeSet {
67    /// Construct an empty changeset.
68    pub const fn new() -> Self {
69        Self {
70            entries: Vec::new(),
71            unmanaged_grants: Vec::new(),
72            revokes_with_owner: Vec::new(),
73        }
74    }
75
76    /// Append a change with its destructiveness classification.
77    pub fn push(&mut self, change: Change, destructiveness: Destructiveness) {
78        self.entries.push(ChangeEntry {
79            change,
80            destructiveness,
81        });
82    }
83
84    /// True iff there are no entries.
85    pub const fn is_empty(&self) -> bool {
86        self.entries.is_empty()
87    }
88
89    /// Number of entries.
90    pub const fn len(&self) -> usize {
91        self.entries.len()
92    }
93
94    /// Iterator over the entries in insertion order.
95    pub fn iter(&self) -> impl Iterator<Item = &ChangeEntry> {
96        self.entries.iter()
97    }
98
99    /// Move every entry from `other` into `self`, including observation vecs.
100    pub fn extend(&mut self, other: Self) {
101        self.entries.extend(other.entries);
102        self.unmanaged_grants.extend(other.unmanaged_grants);
103        self.revokes_with_owner.extend(other.revokes_with_owner);
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::identifier::Identifier;
111
112    fn id(s: &str) -> Identifier {
113        Identifier::from_unquoted(s).unwrap()
114    }
115
116    #[test]
117    fn new_is_empty() {
118        let cs = ChangeSet::new();
119        assert!(cs.is_empty());
120        assert_eq!(cs.len(), 0);
121        assert!(cs.iter().next().is_none());
122    }
123
124    #[test]
125    fn push_then_len_is_one() {
126        let mut cs = ChangeSet::new();
127        cs.push(
128            Change::DropSchema(id("old")),
129            Destructiveness::RequiresApproval {
130                reason: "drops schema".into(),
131            },
132        );
133        assert!(!cs.is_empty());
134        assert_eq!(cs.len(), 1);
135    }
136
137    #[test]
138    fn extend_concatenates() {
139        let mut a = ChangeSet::new();
140        a.push(
141            Change::DropSchema(id("a")),
142            Destructiveness::RequiresApproval { reason: "x".into() },
143        );
144        let mut b = ChangeSet::new();
145        b.push(
146            Change::DropSchema(id("b")),
147            Destructiveness::RequiresApproval { reason: "x".into() },
148        );
149        a.extend(b);
150        assert_eq!(a.len(), 2);
151    }
152}