xerv_core/schema/
compatibility.rs

1//! Schema compatibility analysis and change detection.
2//!
3//! Provides tools for comparing schemas, detecting changes, and
4//! determining if migrations are required.
5
6use super::migration::MigrationRegistry;
7use super::version::ChangeKind;
8use crate::traits::TypeInfo;
9use std::sync::Arc;
10
11/// Compatibility check result.
12///
13/// Contains details about whether two schemas are compatible,
14/// what changes exist between them, and if migration is required.
15#[derive(Debug, Clone)]
16pub struct CompatibilityReport {
17    /// Whether the schemas are directly compatible without requiring migration.
18    pub compatible: bool,
19    /// List of all changes detected between the schemas.
20    pub changes: Vec<SchemaChange>,
21    /// Whether a migration is required to use old data with the new schema.
22    pub migration_required: bool,
23    /// Whether a migration function is available for this transformation.
24    pub migration_available: bool,
25    /// Human-readable summary of the compatibility check result.
26    pub summary: String,
27}
28
29impl CompatibilityReport {
30    /// Create a compatibility report indicating full compatibility.
31    pub fn compatible() -> Self {
32        Self {
33            compatible: true,
34            changes: Vec::new(),
35            migration_required: false,
36            migration_available: true,
37            summary: "Schemas are compatible".to_string(),
38        }
39    }
40
41    /// Create a compatibility report indicating incompatibility.
42    pub fn incompatible(changes: Vec<SchemaChange>, migration_available: bool) -> Self {
43        let breaking_count = changes.iter().filter(|c| c.kind.is_breaking()).count();
44        let summary = if migration_available {
45            format!("{} breaking changes, migration available", breaking_count)
46        } else {
47            format!(
48                "{} breaking changes, no migration available",
49                breaking_count
50            )
51        };
52
53        Self {
54            compatible: false,
55            changes,
56            migration_required: true,
57            migration_available,
58            summary,
59        }
60    }
61
62    /// Check if there are any breaking changes.
63    pub fn has_breaking_changes(&self) -> bool {
64        self.changes.iter().any(|c| c.kind.is_breaking())
65    }
66
67    /// Get all breaking changes.
68    pub fn breaking_changes(&self) -> Vec<&SchemaChange> {
69        self.changes
70            .iter()
71            .filter(|c| c.kind.is_breaking())
72            .collect()
73    }
74
75    /// Get all non-breaking changes.
76    pub fn non_breaking_changes(&self) -> Vec<&SchemaChange> {
77        self.changes
78            .iter()
79            .filter(|c| !c.kind.is_breaking())
80            .collect()
81    }
82}
83
84/// Individual schema change.
85#[derive(Debug, Clone)]
86pub struct SchemaChange {
87    /// The kind of change (e.g., AddOptionalField, RemoveField).
88    pub kind: ChangeKind,
89    /// The name of the field affected by this change.
90    pub field: String,
91    /// The previous type of the field, if applicable.
92    pub old_type: Option<String>,
93    /// The new type of the field, if applicable.
94    pub new_type: Option<String>,
95    /// Human-readable description of what changed and why.
96    pub description: String,
97}
98
99impl SchemaChange {
100    /// Create a new schema change.
101    pub fn new(kind: ChangeKind, field: impl Into<String>) -> Self {
102        let field = field.into();
103        Self {
104            description: format!("{}: {}", kind.description(), field),
105            kind,
106            field,
107            old_type: None,
108            new_type: None,
109        }
110    }
111
112    /// Set the old type.
113    pub fn with_old_type(mut self, old_type: impl Into<String>) -> Self {
114        self.old_type = Some(old_type.into());
115        self
116    }
117
118    /// Set the new type.
119    pub fn with_new_type(mut self, new_type: impl Into<String>) -> Self {
120        self.new_type = Some(new_type.into());
121        self
122    }
123
124    /// Set a custom description.
125    pub fn with_description(mut self, description: impl Into<String>) -> Self {
126        self.description = description.into();
127        self
128    }
129}
130
131impl std::fmt::Display for SchemaChange {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        write!(f, "{}", self.description)?;
134        if let (Some(old), Some(new)) = (&self.old_type, &self.new_type) {
135            write!(f, " ({} -> {})", old, new)?;
136        }
137        Ok(())
138    }
139}
140
141/// Compatibility matrix for schema pairs.
142///
143/// Provides methods for checking compatibility between schemas and
144/// detecting changes.
145pub struct CompatibilityMatrix {
146    /// Migration registry for checking available migrations.
147    migrations: Arc<MigrationRegistry>,
148}
149
150impl CompatibilityMatrix {
151    /// Create a new compatibility matrix.
152    pub fn new(migrations: Arc<MigrationRegistry>) -> Self {
153        Self { migrations }
154    }
155
156    /// Check compatibility between two schemas.
157    pub fn check(&self, from: &TypeInfo, to: &TypeInfo) -> CompatibilityReport {
158        // Same schema hash = identical
159        if from.hash != 0 && from.hash == to.hash {
160            return CompatibilityReport::compatible();
161        }
162
163        // Detect changes
164        let changes = self.detect_changes(from, to);
165
166        if changes.is_empty() {
167            return CompatibilityReport::compatible();
168        }
169
170        // Check for breaking changes
171        let has_breaking = changes.iter().any(|c| c.kind.is_breaking());
172
173        if !has_breaking {
174            // Non-breaking changes only - compatible without migration
175            return CompatibilityReport {
176                compatible: true,
177                changes,
178                migration_required: false,
179                migration_available: true,
180                summary: "Compatible with non-breaking changes".to_string(),
181            };
182        }
183
184        // Breaking changes - check if migration exists
185        let migration_available = self.can_migrate(from.hash, to.hash);
186
187        CompatibilityReport::incompatible(changes, migration_available)
188    }
189
190    /// Detect all changes between two schemas.
191    pub fn detect_changes(&self, from: &TypeInfo, to: &TypeInfo) -> Vec<SchemaChange> {
192        let mut changes = Vec::new();
193
194        // Build field lookup for the source schema
195        let from_fields: std::collections::HashMap<&str, _> =
196            from.fields.iter().map(|f| (f.name.as_str(), f)).collect();
197
198        // Build field lookup for the target schema
199        let to_fields: std::collections::HashMap<&str, _> =
200            to.fields.iter().map(|f| (f.name.as_str(), f)).collect();
201
202        // Check for removed fields
203        for (name, from_field) in &from_fields {
204            if !to_fields.contains_key(name) {
205                changes.push(
206                    SchemaChange::new(ChangeKind::RemoveField, *name)
207                        .with_old_type(&from_field.type_name),
208                );
209            }
210        }
211
212        // Check for added and modified fields
213        for (name, to_field) in &to_fields {
214            match from_fields.get(name) {
215                None => {
216                    // New field
217                    let kind = if to_field.optional {
218                        ChangeKind::AddOptionalField
219                    } else {
220                        ChangeKind::AddRequiredField
221                    };
222                    changes.push(SchemaChange::new(kind, *name).with_new_type(&to_field.type_name));
223                }
224                Some(from_field) => {
225                    // Existing field - check for changes
226                    if from_field.type_name != to_field.type_name {
227                        changes.push(
228                            SchemaChange::new(ChangeKind::ChangeFieldType, *name)
229                                .with_old_type(&from_field.type_name)
230                                .with_new_type(&to_field.type_name),
231                        );
232                    }
233
234                    // Check optional -> required
235                    if from_field.optional && !to_field.optional {
236                        changes.push(SchemaChange::new(ChangeKind::MakeRequired, *name));
237                    }
238
239                    // Check required -> optional
240                    if !from_field.optional && to_field.optional {
241                        changes.push(SchemaChange::new(ChangeKind::MakeOptional, *name));
242                    }
243                }
244            }
245        }
246
247        changes
248    }
249
250    /// Check if runtime migration is possible.
251    pub fn can_migrate(&self, from_hash: u64, to_hash: u64) -> bool {
252        self.migrations.has_path(from_hash, to_hash)
253    }
254
255    /// Detect potential field renames between schemas.
256    ///
257    /// This is a heuristic: fields with the same type but different names
258    /// might be renames. Returns pairs of (old_name, new_name).
259    pub fn detect_potential_renames(
260        &self,
261        from: &TypeInfo,
262        to: &TypeInfo,
263    ) -> Vec<(String, String)> {
264        let mut potential_renames = Vec::new();
265
266        // Find removed fields
267        let removed: Vec<_> = from
268            .fields
269            .iter()
270            .filter(|f| !to.fields.iter().any(|t| t.name == f.name))
271            .collect();
272
273        // Find added fields
274        let added: Vec<_> = to
275            .fields
276            .iter()
277            .filter(|f| !from.fields.iter().any(|t| t.name == f.name))
278            .collect();
279
280        // Match by type
281        for removed_field in &removed {
282            for added_field in &added {
283                if removed_field.type_name == added_field.type_name
284                    && removed_field.optional == added_field.optional
285                {
286                    potential_renames.push((removed_field.name.clone(), added_field.name.clone()));
287                }
288            }
289        }
290
291        potential_renames
292    }
293}
294
295impl Default for CompatibilityMatrix {
296    fn default() -> Self {
297        Self::new(Arc::new(MigrationRegistry::new()))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::traits::FieldInfo;
305
306    fn create_v1_schema() -> TypeInfo {
307        TypeInfo::new("Order", 1).with_hash(100).with_fields(vec![
308            FieldInfo::new("id", "String"),
309            FieldInfo::new("amount", "f64"),
310            FieldInfo::new("status", "String"),
311        ])
312    }
313
314    fn create_v2_schema_compatible() -> TypeInfo {
315        TypeInfo::new("Order", 2).with_hash(200).with_fields(vec![
316            FieldInfo::new("id", "String"),
317            FieldInfo::new("amount", "f64"),
318            FieldInfo::new("status", "String"),
319            FieldInfo::new("notes", "String").optional(),
320        ])
321    }
322
323    fn create_v2_schema_breaking() -> TypeInfo {
324        TypeInfo::new("Order", 2).with_hash(200).with_fields(vec![
325            FieldInfo::new("id", "String"),
326            FieldInfo::new("total", "f64"),  // renamed from amount
327            FieldInfo::new("status", "i32"), // type changed
328        ])
329    }
330
331    #[test]
332    fn identical_schemas() {
333        let matrix = CompatibilityMatrix::default();
334        let v1 = create_v1_schema();
335
336        let report = matrix.check(&v1, &v1);
337        assert!(report.compatible);
338        assert!(report.changes.is_empty());
339    }
340
341    #[test]
342    fn compatible_with_optional_field() {
343        let matrix = CompatibilityMatrix::default();
344        let v1 = create_v1_schema();
345        let v2 = create_v2_schema_compatible();
346
347        let report = matrix.check(&v1, &v2);
348        assert!(report.compatible);
349        assert!(!report.migration_required);
350        assert_eq!(report.changes.len(), 1);
351        assert_eq!(report.changes[0].kind, ChangeKind::AddOptionalField);
352    }
353
354    #[test]
355    fn breaking_changes() {
356        let matrix = CompatibilityMatrix::default();
357        let v1 = create_v1_schema();
358        let v2 = create_v2_schema_breaking();
359
360        let report = matrix.check(&v1, &v2);
361        assert!(!report.compatible);
362        assert!(report.migration_required);
363        assert!(report.has_breaking_changes());
364
365        // Should detect: removed 'amount', added 'total', changed 'status' type
366        let breaking = report.breaking_changes();
367        assert!(!breaking.is_empty());
368    }
369
370    #[test]
371    fn detect_changes_removed_field() {
372        let matrix = CompatibilityMatrix::default();
373
374        let v1 = TypeInfo::new("Test", 1)
375            .with_fields(vec![FieldInfo::new("a", "i32"), FieldInfo::new("b", "i32")]);
376
377        let v2 = TypeInfo::new("Test", 2).with_fields(vec![FieldInfo::new("a", "i32")]);
378
379        let changes = matrix.detect_changes(&v1, &v2);
380        assert_eq!(changes.len(), 1);
381        assert_eq!(changes[0].kind, ChangeKind::RemoveField);
382        assert_eq!(changes[0].field, "b");
383    }
384
385    #[test]
386    fn detect_changes_type_change() {
387        let matrix = CompatibilityMatrix::default();
388
389        let v1 = TypeInfo::new("Test", 1).with_fields(vec![FieldInfo::new("value", "i32")]);
390
391        let v2 = TypeInfo::new("Test", 2).with_fields(vec![FieldInfo::new("value", "f64")]);
392
393        let changes = matrix.detect_changes(&v1, &v2);
394        assert_eq!(changes.len(), 1);
395        assert_eq!(changes[0].kind, ChangeKind::ChangeFieldType);
396        assert_eq!(changes[0].old_type, Some("i32".to_string()));
397        assert_eq!(changes[0].new_type, Some("f64".to_string()));
398    }
399
400    #[test]
401    fn detect_potential_renames() {
402        let matrix = CompatibilityMatrix::default();
403
404        let v1 = TypeInfo::new("Test", 1).with_fields(vec![
405            FieldInfo::new("old_name", "String"),
406            FieldInfo::new("other", "i32"),
407        ]);
408
409        let v2 = TypeInfo::new("Test", 2).with_fields(vec![
410            FieldInfo::new("new_name", "String"),
411            FieldInfo::new("other", "i32"),
412        ]);
413
414        let renames = matrix.detect_potential_renames(&v1, &v2);
415        assert_eq!(renames.len(), 1);
416        assert_eq!(renames[0], ("old_name".to_string(), "new_name".to_string()));
417    }
418
419    #[test]
420    fn with_migration_available() {
421        let migrations = Arc::new(MigrationRegistry::new());
422
423        // Register a migration
424        use super::super::migration::Migration;
425        migrations
426            .register(Migration::new(
427                "Order@v1",
428                100,
429                "Order@v2",
430                200,
431                |_arena, offset| Ok(offset),
432            ))
433            .unwrap();
434
435        let matrix = CompatibilityMatrix::new(migrations);
436        let v1 = create_v1_schema();
437        let v2 = create_v2_schema_breaking();
438
439        let report = matrix.check(&v1, &v2);
440        assert!(!report.compatible);
441        assert!(report.migration_available);
442    }
443}