1use crate::diff::DiffOp;
4use crate::resource::{Catalog, CatalogField};
5
6#[derive(Debug, Clone)]
7pub struct CatalogSchemaDiff {
8 pub name: String,
9 pub op: DiffOp<Catalog>,
10 pub field_diffs: Vec<DiffOp<CatalogField>>,
11}
12
13impl CatalogSchemaDiff {
14 pub fn has_changes(&self) -> bool {
15 self.op.is_change() || self.field_diffs.iter().any(|d| d.is_change())
16 }
17
18 pub fn has_destructive(&self) -> bool {
19 self.op.is_destructive() || self.field_diffs.iter().any(|d| d.is_destructive())
20 }
21}
22
23pub fn diff_schema(local: Option<&Catalog>, remote: Option<&Catalog>) -> Option<CatalogSchemaDiff> {
28 match (local, remote) {
29 (None, None) => None,
30 (Some(l), None) => Some(CatalogSchemaDiff {
31 name: l.name.clone(),
32 op: DiffOp::Added(l.clone()),
33 field_diffs: vec![],
34 }),
35 (None, Some(r)) => Some(CatalogSchemaDiff {
36 name: r.name.clone(),
37 op: DiffOp::Removed(r.clone()),
38 field_diffs: vec![],
39 }),
40 (Some(l), Some(r)) => {
41 let field_diffs = diff_fields(&l.fields, &r.fields);
42 let op = if field_diffs.is_empty() {
48 DiffOp::Unchanged
49 } else {
50 DiffOp::Modified {
51 from: r.clone(),
52 to: l.clone(),
53 }
54 };
55 Some(CatalogSchemaDiff {
56 name: l.name.clone(),
57 op,
58 field_diffs,
59 })
60 }
61 }
62}
63
64fn diff_fields(local: &[CatalogField], remote: &[CatalogField]) -> Vec<DiffOp<CatalogField>> {
72 use std::collections::BTreeMap;
73 let l: BTreeMap<&String, &CatalogField> = local.iter().map(|f| (&f.name, f)).collect();
74 let r: BTreeMap<&String, &CatalogField> = remote.iter().map(|f| (&f.name, f)).collect();
75
76 let mut ops = Vec::new();
77 for (name, lf) in &l {
78 match r.get(name) {
79 None => ops.push(DiffOp::Added((*lf).clone())),
80 Some(rf) if rf != lf => ops.push(DiffOp::Modified {
81 from: (*rf).clone(),
82 to: (*lf).clone(),
83 }),
84 Some(_) => {} }
86 }
87 for (name, rf) in &r {
88 if !l.contains_key(name) {
89 ops.push(DiffOp::Removed((*rf).clone()));
90 }
91 }
92 ops
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98 use crate::resource::CatalogFieldType;
99
100 fn field(name: &str, t: CatalogFieldType) -> CatalogField {
101 CatalogField {
102 name: name.into(),
103 field_type: t,
104 }
105 }
106
107 fn cat(name: &str, fields: Vec<CatalogField>) -> Catalog {
108 Catalog {
109 name: name.into(),
110 description: None,
111 fields,
112 }
113 }
114
115 #[test]
116 fn both_absent_returns_none() {
117 assert!(diff_schema(None, None).is_none());
118 }
119
120 #[test]
121 fn local_only_is_added() {
122 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
123 let d = diff_schema(Some(&l), None).unwrap();
124 assert!(matches!(d.op, DiffOp::Added(_)));
125 assert!(d.has_changes());
126 assert!(!d.has_destructive());
127 }
128
129 #[test]
130 fn remote_only_is_removed_and_destructive() {
131 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
132 let d = diff_schema(None, Some(&r)).unwrap();
133 assert!(matches!(d.op, DiffOp::Removed(_)));
134 assert!(d.has_changes());
135 assert!(d.has_destructive());
136 }
137
138 #[test]
139 fn equal_catalogs_are_unchanged() {
140 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
141 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
142 let d = diff_schema(Some(&l), Some(&r)).unwrap();
143 assert!(matches!(d.op, DiffOp::Unchanged));
144 assert!(d.field_diffs.is_empty());
145 assert!(!d.has_changes());
146 assert!(!d.has_destructive());
147 }
148
149 #[test]
150 fn added_field_is_non_destructive() {
151 let l = cat(
152 "c",
153 vec![
154 field("id", CatalogFieldType::String),
155 field("score", CatalogFieldType::Number),
156 ],
157 );
158 let r = cat("c", vec![field("id", CatalogFieldType::String)]);
159 let d = diff_schema(Some(&l), Some(&r)).unwrap();
160 assert!(matches!(d.op, DiffOp::Modified { .. }));
161 assert_eq!(d.field_diffs.len(), 1);
162 assert!(matches!(d.field_diffs[0], DiffOp::Added(_)));
163 assert!(d.has_changes());
164 assert!(!d.has_destructive());
165 }
166
167 #[test]
168 fn removed_field_is_destructive() {
169 let l = cat("c", vec![field("id", CatalogFieldType::String)]);
170 let r = cat(
171 "c",
172 vec![
173 field("id", CatalogFieldType::String),
174 field("legacy", CatalogFieldType::String),
175 ],
176 );
177 let d = diff_schema(Some(&l), Some(&r)).unwrap();
178 assert_eq!(d.field_diffs.len(), 1);
179 assert!(matches!(d.field_diffs[0], DiffOp::Removed(_)));
180 assert!(d.has_destructive());
181 }
182
183 #[test]
184 fn type_change_is_modified_field() {
185 let l = cat("c", vec![field("v", CatalogFieldType::Number)]);
186 let r = cat("c", vec![field("v", CatalogFieldType::String)]);
187 let d = diff_schema(Some(&l), Some(&r)).unwrap();
188 assert_eq!(d.field_diffs.len(), 1);
189 assert!(matches!(d.field_diffs[0], DiffOp::Modified { .. }));
190 assert!(d.has_changes());
191 assert!(!d.has_destructive());
193 }
194
195 #[test]
196 fn unchanged_fields_are_not_recorded() {
197 let l = cat(
198 "c",
199 vec![
200 field("id", CatalogFieldType::String),
201 field("score", CatalogFieldType::Number),
202 ],
203 );
204 let r = cat(
205 "c",
206 vec![
207 field("id", CatalogFieldType::String),
208 field("score", CatalogFieldType::Number),
209 ],
210 );
211 let d = diff_schema(Some(&l), Some(&r)).unwrap();
212 assert!(d.field_diffs.is_empty());
213 }
214
215 #[test]
216 fn field_order_difference_is_not_drift() {
217 let l = cat(
218 "c",
219 vec![
220 field("a", CatalogFieldType::String),
221 field("b", CatalogFieldType::Number),
222 ],
223 );
224 let r = cat(
225 "c",
226 vec![
227 field("b", CatalogFieldType::Number),
228 field("a", CatalogFieldType::String),
229 ],
230 );
231 let d = diff_schema(Some(&l), Some(&r)).unwrap();
232 assert!(matches!(d.op, DiffOp::Unchanged));
235 assert!(d.field_diffs.is_empty());
236 assert!(!d.has_changes());
237 }
238
239 #[test]
240 fn description_only_difference_is_not_drift() {
241 let l = Catalog {
242 name: "c".into(),
243 description: Some("local description".into()),
244 fields: vec![field("id", CatalogFieldType::String)],
245 };
246 let r = Catalog {
247 name: "c".into(),
248 description: Some("remote description".into()),
249 fields: vec![field("id", CatalogFieldType::String)],
250 };
251 let d = diff_schema(Some(&l), Some(&r)).unwrap();
252 assert!(matches!(d.op, DiffOp::Unchanged));
253 assert!(d.field_diffs.is_empty());
254 assert!(!d.has_changes());
255 }
256}