Skip to main content

pgevolve_core/diff/
collations.rs

1//! Differ for collations. Per-collation granular diff:
2//! - Structural change (provider / `lc_collate` / `lc_ctype` / deterministic) →
3//!   `Replace` (skip the rest); `version` is read-only and ignored.
4//! - owner differs (lenient) → `AlterObjectOwner`.
5//! - comment differs → `CommentOn`.
6//!
7//! Lenient: target-only collations do NOT emit `Drop` (surfaces via the
8//! `unmanaged-collation` lint in a later stage). Rename is not emitted
9//! either — name mismatch is treated as drop-old (lenient skip) plus
10//! create-new, since rename intent is not structurally derivable.
11
12use std::collections::{BTreeMap, BTreeSet};
13
14use crate::diff::change::{Change, CollationChange};
15use crate::diff::changeset::ChangeSet;
16use crate::diff::destructiveness::Destructiveness;
17use crate::diff::owner_grants::{ColumnGrantMode, diff_owner_and_grants};
18use crate::diff::owner_op::CatalogObjectRef;
19use crate::identifier::QualifiedName;
20use crate::ir::catalog::Catalog;
21use crate::ir::collation::Collation;
22
23/// Compute granular collation changes needed to converge `target` toward
24/// `source`. Appends all emitted changes to `out`.
25pub fn diff_collations(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
26    let target_map: BTreeMap<&QualifiedName, &Collation> =
27        target.collations.iter().map(|c| (&c.qname, c)).collect();
28    let source_map: BTreeMap<&QualifiedName, &Collation> =
29        source.collations.iter().map(|c| (&c.qname, c)).collect();
30
31    // Creates: in source but not in target.
32    for (qname, src) in &source_map {
33        if !target_map.contains_key(qname) {
34            out.push(
35                Change::Collation(CollationChange::Create((*src).clone())),
36                Destructiveness::Safe,
37            );
38        }
39    }
40
41    // Target-only: lenient — no auto-drop. Surfaces via unmanaged-collation lint.
42
43    // Modifies: in both.
44    for (qname, src) in &source_map {
45        let Some(tgt) = target_map.get(qname) else {
46            continue;
47        };
48        diff_one(tgt, src, out);
49    }
50}
51
52fn diff_one(target: &Collation, source: &Collation, out: &mut ChangeSet) {
53    // Structural change (provider / lc_collate / lc_ctype / deterministic)
54    // → Replace; skip the rest for this collation. `version` is read-only and
55    // intentionally not compared.
56    if (
57        target.provider,
58        &target.lc_collate,
59        &target.lc_ctype,
60        target.deterministic,
61    ) != (
62        source.provider,
63        &source.lc_collate,
64        &source.lc_ctype,
65        source.deterministic,
66    ) {
67        out.push(
68            Change::Collation(CollationChange::Replace {
69                from: target.clone(),
70                to: source.clone(),
71            }),
72            Destructiveness::RequiresApproval {
73                reason: format!(
74                    "structural change to collation {} requires DROP + CREATE (PG has no in-place ALTER for provider/locale/deterministic)",
75                    source.qname
76                ),
77            },
78        );
79        return;
80    }
81
82    // Owner: lenient — only emit when source declares an owner and it differs.
83    // Collations have no grants, so the grant slices are empty.
84    diff_owner_and_grants(
85        &CatalogObjectRef::Collation(source.qname.clone()),
86        target.owner.as_ref(),
87        source.owner.as_ref(),
88        &[],
89        &[],
90        &BTreeSet::new(),
91        ColumnGrantMode::ObjectOnly,
92        out,
93    );
94
95    // Comment.
96    if target.comment != source.comment {
97        out.push(
98            Change::Collation(CollationChange::CommentOn {
99                qname: source.qname.clone(),
100                comment: source.comment.clone(),
101            }),
102            Destructiveness::Safe,
103        );
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::identifier::Identifier;
111    use crate::ir::collation::CollationProvider;
112
113    fn id(s: &str) -> Identifier {
114        Identifier::from_unquoted(s).unwrap()
115    }
116
117    fn qn(schema: &str, name: &str) -> QualifiedName {
118        QualifiedName::new(id(schema), id(name))
119    }
120
121    fn basic_collation(name: &str) -> Collation {
122        Collation {
123            qname: qn("app", name),
124            provider: CollationProvider::Libc,
125            lc_collate: "en_US.utf8".into(),
126            lc_ctype: "en_US.utf8".into(),
127            deterministic: true,
128            version: None,
129            owner: None,
130            comment: None,
131        }
132    }
133
134    fn catalog_with(collations: Vec<Collation>) -> Catalog {
135        let mut c = Catalog::empty();
136        c.collations = collations;
137        c
138    }
139
140    fn run_diff(target: &Catalog, source: &Catalog) -> ChangeSet {
141        let mut out = ChangeSet::new();
142        diff_collations(target, source, &mut out);
143        out
144    }
145
146    // ---- creates ----
147
148    #[test]
149    fn create_collation_when_source_has_it_and_target_doesnt() {
150        let target = Catalog::empty();
151        let source = catalog_with(vec![basic_collation("c")]);
152        let changes = run_diff(&target, &source);
153        assert_eq!(changes.len(), 1);
154        assert!(matches!(
155            changes.iter().next().unwrap().change,
156            Change::Collation(CollationChange::Create(_))
157        ));
158    }
159
160    #[test]
161    fn create_collation_is_safe() {
162        let target = Catalog::empty();
163        let source = catalog_with(vec![basic_collation("c")]);
164        let changes = run_diff(&target, &source);
165        let entry = changes.iter().next().unwrap();
166        assert!(!entry.destructiveness.requires_approval());
167    }
168
169    // ---- lenient: no auto-drop ----
170
171    #[test]
172    fn no_drop_when_target_has_collation_but_source_doesnt() {
173        let target = catalog_with(vec![basic_collation("c")]);
174        let source = Catalog::empty();
175        let changes = run_diff(&target, &source);
176        assert!(
177            changes.is_empty(),
178            "expected no changes (lenient), got {changes:?}"
179        );
180    }
181
182    // ---- identical: no diff ----
183
184    #[test]
185    fn identical_collations_produce_no_changes() {
186        let c = catalog_with(vec![basic_collation("c")]);
187        let changes = run_diff(&c, &c);
188        assert!(changes.is_empty());
189    }
190
191    // ---- structural changes → Replace ----
192
193    #[test]
194    fn provider_differs_emits_replace() {
195        let mut src = basic_collation("c");
196        src.provider = CollationProvider::Icu;
197        let target = catalog_with(vec![basic_collation("c")]);
198        let source = catalog_with(vec![src]);
199        let changes = run_diff(&target, &source);
200        assert_eq!(changes.len(), 1);
201        let entry = changes.iter().next().unwrap();
202        assert!(matches!(
203            entry.change,
204            Change::Collation(CollationChange::Replace { .. })
205        ));
206        assert!(entry.destructiveness.requires_approval());
207    }
208
209    #[test]
210    fn lc_collate_differs_emits_replace() {
211        let mut src = basic_collation("c");
212        src.lc_collate = "de_DE.utf8".into();
213        let target = catalog_with(vec![basic_collation("c")]);
214        let source = catalog_with(vec![src]);
215        let changes = run_diff(&target, &source);
216        assert_eq!(changes.len(), 1);
217        assert!(matches!(
218            changes.iter().next().unwrap().change,
219            Change::Collation(CollationChange::Replace { .. })
220        ));
221    }
222
223    #[test]
224    fn lc_ctype_differs_emits_replace() {
225        let mut src = basic_collation("c");
226        src.lc_ctype = "de_DE.utf8".into();
227        let target = catalog_with(vec![basic_collation("c")]);
228        let source = catalog_with(vec![src]);
229        let changes = run_diff(&target, &source);
230        assert_eq!(changes.len(), 1);
231        assert!(matches!(
232            changes.iter().next().unwrap().change,
233            Change::Collation(CollationChange::Replace { .. })
234        ));
235    }
236
237    #[test]
238    fn deterministic_differs_emits_replace() {
239        let mut tgt = basic_collation("c");
240        tgt.provider = CollationProvider::Icu;
241        tgt.lc_collate = "und".into();
242        tgt.lc_ctype = "und".into();
243        tgt.deterministic = true;
244        let mut src = tgt.clone();
245        src.deterministic = false;
246        let target = catalog_with(vec![tgt]);
247        let source = catalog_with(vec![src]);
248        let changes = run_diff(&target, &source);
249        assert_eq!(changes.len(), 1);
250        assert!(matches!(
251            changes.iter().next().unwrap().change,
252            Change::Collation(CollationChange::Replace { .. })
253        ));
254    }
255
256    #[test]
257    fn version_differs_does_not_emit_change() {
258        // version is read-only — must be ignored.
259        let mut tgt = basic_collation("c");
260        tgt.version = Some("153.97".into());
261        let mut src = basic_collation("c");
262        src.version = Some("153.128".into());
263        let target = catalog_with(vec![tgt]);
264        let source = catalog_with(vec![src]);
265        let changes = run_diff(&target, &source);
266        assert!(
267            changes.is_empty(),
268            "version field must be ignored by differ, got {changes:?}"
269        );
270    }
271
272    #[test]
273    fn structural_change_skips_downstream_per_field_checks() {
274        // Even if owner + comment also differ, only Replace is emitted.
275        let mut tgt = basic_collation("c");
276        tgt.owner = Some(id("alice"));
277        tgt.comment = Some("old".into());
278
279        let mut src = basic_collation("c");
280        src.provider = CollationProvider::Icu; // structural diff
281        src.owner = Some(id("bob"));
282        src.comment = Some("new".into());
283
284        let target = catalog_with(vec![tgt]);
285        let source = catalog_with(vec![src]);
286        let changes = run_diff(&target, &source);
287        assert_eq!(changes.len(), 1, "only Replace, no downstream diffs");
288        assert!(matches!(
289            changes.iter().next().unwrap().change,
290            Change::Collation(CollationChange::Replace { .. })
291        ));
292    }
293
294    // ---- owner diff ----
295
296    #[test]
297    fn owner_change_emits_alter_object_owner() {
298        let mut tgt = basic_collation("c");
299        tgt.owner = Some(id("alice"));
300        let mut src = basic_collation("c");
301        src.owner = Some(id("bob"));
302        let target = catalog_with(vec![tgt]);
303        let source = catalog_with(vec![src]);
304        let changes = run_diff(&target, &source);
305        assert_eq!(changes.len(), 1);
306        let entry = changes.iter().next().unwrap();
307        if let Change::AlterObjectOwner(op) = &entry.change {
308            assert!(matches!(op.object, CatalogObjectRef::Collation(_)));
309            assert_eq!(op.to, id("bob"));
310        } else {
311            panic!("expected AlterObjectOwner, got {:?}", entry.change);
312        }
313    }
314
315    #[test]
316    fn no_owner_change_when_source_owner_is_none() {
317        // Source `None` = unmanaged; no change emitted.
318        let mut tgt = basic_collation("c");
319        tgt.owner = Some(id("alice"));
320        let src = basic_collation("c"); // owner = None
321        let target = catalog_with(vec![tgt]);
322        let source = catalog_with(vec![src]);
323        let changes = run_diff(&target, &source);
324        assert!(
325            changes.is_empty(),
326            "source owner None = unmanaged, no change expected"
327        );
328    }
329
330    // ---- comment diff ----
331
332    #[test]
333    fn comment_change_emits_comment_on() {
334        let src = {
335            let mut s = basic_collation("c");
336            s.comment = Some("pinned for sorting".into());
337            s
338        };
339        let target = catalog_with(vec![basic_collation("c")]);
340        let source = catalog_with(vec![src]);
341        let changes = run_diff(&target, &source);
342        assert_eq!(changes.len(), 1);
343        assert!(matches!(
344            changes.iter().next().unwrap().change,
345            Change::Collation(CollationChange::CommentOn { .. })
346        ));
347    }
348
349    #[test]
350    fn clear_comment_emits_comment_on_with_none() {
351        let mut tgt = basic_collation("c");
352        tgt.comment = Some("old comment".into());
353        let src = basic_collation("c"); // comment = None
354        let target = catalog_with(vec![tgt]);
355        let source = catalog_with(vec![src]);
356        let changes = run_diff(&target, &source);
357        assert_eq!(changes.len(), 1);
358        let entry = changes.iter().next().unwrap();
359        if let Change::Collation(CollationChange::CommentOn { comment, .. }) = &entry.change {
360            assert!(comment.is_none());
361        } else {
362            panic!(
363                "expected CollationChange::CommentOn, got {:?}",
364                entry.change
365            );
366        }
367    }
368
369    // ---- partial overlap ----
370
371    #[test]
372    fn owner_and_comment_both_changed_emit_two_changes() {
373        let mut tgt = basic_collation("c");
374        tgt.owner = Some(id("alice"));
375        let mut src = basic_collation("c");
376        src.owner = Some(id("bob"));
377        src.comment = Some("new comment".into());
378        let target = catalog_with(vec![tgt]);
379        let source = catalog_with(vec![src]);
380        let changes = run_diff(&target, &source);
381        assert_eq!(changes.len(), 2);
382        assert!(
383            changes
384                .iter()
385                .any(|e| matches!(&e.change, Change::AlterObjectOwner(_)))
386        );
387        assert!(changes.iter().any(|e| matches!(
388            &e.change,
389            Change::Collation(CollationChange::CommentOn { .. })
390        )));
391    }
392}