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