Skip to main content

pgevolve_core/diff/
casts.rs

1//! Differ for `Catalog::casts`.
2//!
3//! Casts are **managed** (global, non-schema-scoped): a live cast that is absent
4//! from source IS auto-dropped — unlike lenient objects such as event triggers
5//! or statistics.
6//!
7//! Identity is `(source, target)`. Because
8//! [`QualifiedName`](crate::identifier::QualifiedName) does not implement
9//! [`Ord`], the `BTreeMap` key is `(String, String)` where each `String` is
10//! `QualifiedName::render_sql()` — a stable, canonical representation.
11//!
12//! Logic summary:
13//! - source-only → `Create` (Safe).
14//! - target-only → `Drop` (Safe — casts carry no data).
15//! - both present, `method` or `context` differ → `Replace` (Safe — Postgres
16//!   has no `ALTER CAST`; subsumes comment).
17//! - else: comment differs → `CommentOn` (Safe).
18
19use std::collections::BTreeMap;
20
21use crate::diff::change::{CastChange, Change};
22use crate::diff::changeset::ChangeSet;
23use crate::diff::destructiveness::Destructiveness;
24use crate::ir::cast::Cast;
25use crate::ir::catalog::Catalog;
26
27/// The `BTreeMap` key for a cast: canonical `(rendered source, rendered target)`.
28type CastKey = (String, String);
29
30fn cast_key(c: &Cast) -> CastKey {
31    (c.source.render_sql(), c.target.render_sql())
32}
33
34/// Compute cast changes needed to converge `target` (live) toward `source`.
35///
36/// Appends all emitted changes to `out`. Casts are **managed**: a target-only
37/// cast (absent from source) IS auto-dropped.
38pub fn diff_casts(target: &Catalog, source: &Catalog, out: &mut ChangeSet) {
39    let target_map: BTreeMap<CastKey, &Cast> =
40        target.casts.iter().map(|c| (cast_key(c), c)).collect();
41    let source_map: BTreeMap<CastKey, &Cast> =
42        source.casts.iter().map(|c| (cast_key(c), c)).collect();
43
44    // Source-only → Create.
45    for (key, src) in &source_map {
46        if !target_map.contains_key(key) {
47            out.push(
48                Change::Cast(CastChange::Create((*src).clone())),
49                Destructiveness::Safe,
50            );
51        }
52    }
53
54    // Target-only → Drop (managed, not lenient).
55    for (key, tgt) in &target_map {
56        if !source_map.contains_key(key) {
57            out.push(
58                Change::Cast(CastChange::Drop {
59                    source: tgt.source.clone(),
60                    target: tgt.target.clone(),
61                }),
62                Destructiveness::Safe,
63            );
64        }
65    }
66
67    // Both present → granular diff.
68    for (key, src) in &source_map {
69        let Some(tgt) = target_map.get(key) else {
70            continue;
71        };
72        emit_modify(tgt, src, out);
73    }
74}
75
76fn emit_modify(t: &Cast, s: &Cast, out: &mut ChangeSet) {
77    // Any structural change (method or context) requires DROP + CREATE.
78    if t.method != s.method || t.context != s.context {
79        out.push(
80            Change::Cast(CastChange::Replace {
81                from: t.clone(),
82                to: s.clone(),
83            }),
84            Destructiveness::Safe,
85        );
86        return;
87    }
88
89    // Comment differs → CommentOn (both directions: set or clear).
90    if t.comment != s.comment {
91        out.push(
92            Change::Cast(CastChange::CommentOn {
93                source: s.source.clone(),
94                target: s.target.clone(),
95                comment: s.comment.clone(),
96            }),
97            Destructiveness::Safe,
98        );
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::diff::change::{CastChange, Change};
106    use crate::identifier::{Identifier, QualifiedName};
107    use crate::ir::cast::{CastContext, CastMethod};
108    use crate::ir::catalog::Catalog;
109    use crate::ir::column_type::ColumnType;
110
111    fn id(s: &str) -> Identifier {
112        Identifier::from_unquoted(s).unwrap()
113    }
114
115    fn qn(schema: &str, name: &str) -> QualifiedName {
116        QualifiedName::new(id(schema), id(name))
117    }
118
119    /// Build a minimal cast: `app.my_type` → `pg_catalog.text`, binary, explicit.
120    fn basic_cast() -> Cast {
121        Cast {
122            source: qn("app", "my_type"),
123            target: qn("pg_catalog", "text"),
124            method: CastMethod::Binary,
125            context: CastContext::Explicit,
126            comment: None,
127        }
128    }
129
130    fn cat(casts: Vec<Cast>) -> Catalog {
131        let mut c = Catalog::empty();
132        c.casts = casts;
133        c
134    }
135
136    fn run(target: &Catalog, source: &Catalog) -> ChangeSet {
137        let mut out = ChangeSet::new();
138        diff_casts(target, source, &mut out);
139        out
140    }
141
142    // ---- source-only → Create ----
143
144    #[test]
145    fn source_only_creates() {
146        let changes = run(&cat(vec![]), &cat(vec![basic_cast()]));
147        assert_eq!(changes.len(), 1);
148        assert!(matches!(
149            changes.iter().next().unwrap().change,
150            Change::Cast(CastChange::Create(_))
151        ));
152    }
153
154    // ---- target-only → Drop (managed, NOT lenient) ----
155
156    #[test]
157    fn target_only_drops() {
158        let changes = run(&cat(vec![basic_cast()]), &cat(vec![]));
159        assert_eq!(
160            changes.len(),
161            1,
162            "managed cast must emit Drop when absent from source"
163        );
164        assert!(
165            matches!(
166                changes.iter().next().unwrap().change,
167                Change::Cast(CastChange::Drop { .. })
168            ),
169            "expected Drop, got {:?}",
170            changes.iter().next().unwrap().change
171        );
172    }
173
174    // ---- method differs → Replace ----
175
176    #[test]
177    fn different_method_binary_vs_function_replaces() {
178        let t = basic_cast(); // Binary
179        let mut s = basic_cast();
180        s.method = CastMethod::Function {
181            name: qn("app", "my_cast_fn"),
182            arg_types: vec![ColumnType::Integer],
183        };
184        let changes = run(&cat(vec![t]), &cat(vec![s]));
185        assert_eq!(changes.len(), 1);
186        assert!(matches!(
187            changes.iter().next().unwrap().change,
188            Change::Cast(CastChange::Replace { .. })
189        ));
190    }
191
192    #[test]
193    fn different_method_binary_vs_inout_replaces() {
194        let t = basic_cast(); // Binary
195        let mut s = basic_cast();
196        s.method = CastMethod::Inout;
197        let changes = run(&cat(vec![t]), &cat(vec![s]));
198        assert_eq!(changes.len(), 1);
199        assert!(matches!(
200            changes.iter().next().unwrap().change,
201            Change::Cast(CastChange::Replace { .. })
202        ));
203    }
204
205    // ---- context differs → Replace ----
206
207    #[test]
208    fn different_context_explicit_vs_implicit_replaces() {
209        let t = basic_cast(); // Explicit
210        let mut s = basic_cast();
211        s.context = CastContext::Implicit;
212        let changes = run(&cat(vec![t]), &cat(vec![s]));
213        assert_eq!(changes.len(), 1);
214        assert!(matches!(
215            changes.iter().next().unwrap().change,
216            Change::Cast(CastChange::Replace { .. })
217        ));
218    }
219
220    #[test]
221    fn different_context_explicit_vs_assignment_replaces() {
222        let t = basic_cast(); // Explicit
223        let mut s = basic_cast();
224        s.context = CastContext::Assignment;
225        let changes = run(&cat(vec![t]), &cat(vec![s]));
226        assert_eq!(changes.len(), 1);
227        assert!(matches!(
228            changes.iter().next().unwrap().change,
229            Change::Cast(CastChange::Replace { .. })
230        ));
231    }
232
233    // ---- Replace from/to direction: from=target(live), to=source(desired) ----
234
235    #[test]
236    fn replace_from_is_target_to_is_source() {
237        let mut t = basic_cast();
238        t.context = CastContext::Implicit; // live state
239        let mut s = basic_cast();
240        s.context = CastContext::Explicit; // desired state
241        let changes = run(&cat(vec![t.clone()]), &cat(vec![s.clone()]));
242        assert_eq!(changes.len(), 1);
243        let entry = changes.iter().next().unwrap();
244        if let Change::Cast(CastChange::Replace { from, to }) = &entry.change {
245            assert_eq!(from, &t, "from should be the target (live) cast");
246            assert_eq!(to, &s, "to should be the source (desired) cast");
247        } else {
248            panic!("expected Replace, got {:?}", entry.change);
249        }
250    }
251
252    // ---- only comment differs → CommentOn ----
253
254    #[test]
255    fn comment_change_emits_comment_on() {
256        let t = basic_cast(); // comment = None
257        let mut s = basic_cast();
258        s.comment = Some("converts my_type to text".into());
259        let changes = run(&cat(vec![t]), &cat(vec![s]));
260        assert_eq!(changes.len(), 1);
261        let entry = changes.iter().next().unwrap();
262        assert!(
263            matches!(
264                &entry.change,
265                Change::Cast(CastChange::CommentOn { comment, .. }) if comment.as_deref() == Some("converts my_type to text")
266            ),
267            "expected CommentOn with source comment, got {:?}",
268            entry.change
269        );
270    }
271
272    #[test]
273    fn comment_clear_emits_comment_on_none() {
274        let mut t = basic_cast();
275        t.comment = Some("old comment".into()); // live has comment
276        let s = basic_cast(); // desired has no comment
277        let changes = run(&cat(vec![t]), &cat(vec![s]));
278        assert_eq!(changes.len(), 1);
279        assert!(matches!(
280            changes.iter().next().unwrap().change,
281            Change::Cast(CastChange::CommentOn { comment: None, .. })
282        ));
283    }
284
285    // ---- identical → no changes ----
286
287    #[test]
288    fn identical_casts_produce_no_changes() {
289        let cast = basic_cast();
290        let c = cat(vec![cast]);
291        let changes = run(&c, &c);
292        assert!(changes.is_empty());
293    }
294
295    // ---- different type pairs are distinct identities → Create + Drop ----
296
297    #[test]
298    fn different_source_type_are_distinct_identities() {
299        // target has (app.my_type → pg_catalog.text)
300        // source has (app.other_type → pg_catalog.text)
301        // → Drop + Create
302        let t = basic_cast();
303        let mut s = basic_cast();
304        s.source = qn("app", "other_type");
305        let changes = run(&cat(vec![t]), &cat(vec![s]));
306        assert_eq!(
307            changes.len(),
308            2,
309            "different source type = distinct identity: Drop + Create"
310        );
311        assert!(
312            changes
313                .iter()
314                .any(|e| matches!(&e.change, Change::Cast(CastChange::Create(_)))),
315            "expected Create"
316        );
317        assert!(
318            changes
319                .iter()
320                .any(|e| matches!(&e.change, Change::Cast(CastChange::Drop { .. }))),
321            "expected Drop"
322        );
323    }
324}