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