Skip to main content

pgevolve_core/diff/
extensions.rs

1//! Differ for `Catalog::extensions`.
2//!
3//! Pair-by-name. Source can leave `schema` or `version` as `None` to mean
4//! "don't care"; the differ never emits a change when the source side is
5//! `None` for the relevant field.
6
7use std::collections::BTreeMap;
8
9use crate::diff::change::{Change, ExtensionChange};
10use crate::diff::changeset::ChangeSet;
11use crate::diff::destructiveness::Destructiveness;
12use crate::ir::extension::Extension;
13
14/// Compute extension-level changes needed to converge `target` toward `source`.
15///
16/// Pair-by-name semantics: source extensions with `schema = None` or
17/// `version = None` match any catalog value for that field.
18pub fn diff_extensions(target: &[Extension], source: &[Extension], out: &mut ChangeSet) {
19    let target_by_name: BTreeMap<_, _> = target.iter().map(|e| (e.name.clone(), e)).collect();
20    let source_by_name: BTreeMap<_, _> = source.iter().map(|e| (e.name.clone(), e)).collect();
21
22    // Drops: in target but not source.
23    for name in target_by_name.keys() {
24        if !source_by_name.contains_key(name) {
25            out.push(
26                Change::Extension(ExtensionChange::Drop(name.clone())),
27                Destructiveness::RequiresApprovalAndDataLossWarning {
28                    reason: format!(
29                        "DROP EXTENSION {name} CASCADE removes every object owned by the extension."
30                    ),
31                },
32            );
33        }
34    }
35
36    // Creates and alters.
37    for (name, s) in &source_by_name {
38        match target_by_name.get(name) {
39            None => out.push(
40                Change::Extension(ExtensionChange::Create((*s).clone())),
41                Destructiveness::Safe,
42            ),
43            Some(t) => {
44                // Schema mismatch (source-None matches anything).
45                if let Some(source_schema) = &s.schema
46                    && t.schema.as_ref() != Some(source_schema)
47                {
48                    out.push(
49                        Change::Extension(ExtensionChange::ReplaceWithCascade((*s).clone())),
50                        Destructiveness::RequiresApprovalAndDataLossWarning {
51                            reason: format!(
52                                "Changing the schema of extension {name} requires DROP CASCADE; \
53                                 every object owned by the extension is removed and re-created."
54                            ),
55                        },
56                    );
57                    continue;
58                }
59                // Version mismatch (source-None matches anything).
60                if let Some(source_version) = &s.version
61                    && t.version.as_ref() != Some(source_version)
62                {
63                    out.push(
64                        Change::Extension(ExtensionChange::AlterUpdate {
65                            name: name.clone(),
66                            to_version: source_version.clone(),
67                        }),
68                        Destructiveness::Safe,
69                    );
70                }
71                // Comment mismatch (source-None means "don't care" — consistent
72                // with how schema and version are treated above). Extensions
73                // often ship with auto-assigned PG descriptions; if the source
74                // omits the comment field, we leave whatever PG has in place.
75                if let Some(source_comment) = &s.comment
76                    && t.comment.as_ref() != Some(source_comment)
77                {
78                    out.push(
79                        Change::Extension(ExtensionChange::CommentOn {
80                            name: name.clone(),
81                            comment: s.comment.clone(),
82                        }),
83                        Destructiveness::Safe,
84                    );
85                }
86            }
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::identifier::Identifier;
95
96    fn id(s: &str) -> Identifier {
97        Identifier::from_unquoted(s).unwrap()
98    }
99    fn ext(name: &str) -> Extension {
100        Extension {
101            name: id(name),
102            schema: None,
103            version: None,
104            comment: None,
105        }
106    }
107
108    #[test]
109    fn create_when_only_in_source() {
110        let mut cs = ChangeSet::new();
111        diff_extensions(&[], &[ext("pgcrypto")], &mut cs);
112        assert!(matches!(
113            cs.iter().next().map(|e| &e.change),
114            Some(Change::Extension(ExtensionChange::Create(_)))
115        ));
116    }
117
118    #[test]
119    fn drop_when_only_in_target() {
120        let mut cs = ChangeSet::new();
121        diff_extensions(&[ext("pgcrypto")], &[], &mut cs);
122        let first = cs.iter().next().expect("one change");
123        assert!(matches!(
124            &first.change,
125            Change::Extension(ExtensionChange::Drop(_))
126        ));
127        assert!(matches!(
128            &first.destructiveness,
129            Destructiveness::RequiresApprovalAndDataLossWarning { .. }
130        ));
131    }
132
133    #[test]
134    fn version_unpinned_in_source_matches_any_catalog_version() {
135        let mut t = ext("pgcrypto");
136        t.version = Some("1.3".into());
137        let s = ext("pgcrypto"); // source unpinned
138        let mut cs = ChangeSet::new();
139        diff_extensions(&[t], &[s], &mut cs);
140        assert!(cs.iter().next().is_none(), "unpinned source must not diff");
141    }
142
143    #[test]
144    fn version_pinned_in_source_triggers_alter_update() {
145        let mut t = ext("pgcrypto");
146        t.version = Some("1.3".into());
147        let mut s = ext("pgcrypto");
148        s.version = Some("1.4".into());
149        let mut cs = ChangeSet::new();
150        diff_extensions(&[t], &[s], &mut cs);
151        let first = cs.iter().next().expect("one change");
152        assert!(matches!(
153            &first.change,
154            Change::Extension(ExtensionChange::AlterUpdate { to_version, .. })
155                if to_version == "1.4"
156        ));
157    }
158
159    #[test]
160    fn schema_change_triggers_replace_with_cascade() {
161        let mut t = ext("pgcrypto");
162        t.schema = Some(id("public"));
163        let mut s = ext("pgcrypto");
164        s.schema = Some(id("app"));
165        let mut cs = ChangeSet::new();
166        diff_extensions(&[t], &[s], &mut cs);
167        let first = cs.iter().next().expect("one change");
168        assert!(matches!(
169            &first.change,
170            Change::Extension(ExtensionChange::ReplaceWithCascade(_))
171        ));
172        assert!(matches!(
173            &first.destructiveness,
174            Destructiveness::RequiresApprovalAndDataLossWarning { .. }
175        ));
176    }
177
178    #[test]
179    fn schema_unpinned_in_source_skips_schema_diff() {
180        let mut t = ext("pgcrypto");
181        t.schema = Some(id("public"));
182        let s = ext("pgcrypto"); // unpinned schema
183        let mut cs = ChangeSet::new();
184        diff_extensions(&[t], &[s], &mut cs);
185        assert!(cs.iter().next().is_none());
186    }
187}