1use 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
27type CastKey = (String, String);
29
30fn cast_key(c: &Cast) -> CastKey {
31 (c.source.render_sql(), c.target.render_sql())
32}
33
34pub 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 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 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 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 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 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 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 #[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 #[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 #[test]
177 fn different_method_binary_vs_function_replaces() {
178 let t = basic_cast(); 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(); 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 #[test]
208 fn different_context_explicit_vs_implicit_replaces() {
209 let t = basic_cast(); 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(); 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 #[test]
236 fn replace_from_is_target_to_is_source() {
237 let mut t = basic_cast();
238 t.context = CastContext::Implicit; let mut s = basic_cast();
240 s.context = CastContext::Explicit; 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 #[test]
255 fn comment_change_emits_comment_on() {
256 let t = basic_cast(); 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()); let s = basic_cast(); 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 #[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 #[test]
298 fn different_source_type_are_distinct_identities() {
299 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}