use super::migration::MigrationRegistry;
use super::version::ChangeKind;
use crate::traits::TypeInfo;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct CompatibilityReport {
pub compatible: bool,
pub changes: Vec<SchemaChange>,
pub migration_required: bool,
pub migration_available: bool,
pub summary: String,
}
impl CompatibilityReport {
pub fn compatible() -> Self {
Self {
compatible: true,
changes: Vec::new(),
migration_required: false,
migration_available: true,
summary: "Schemas are compatible".to_string(),
}
}
pub fn incompatible(changes: Vec<SchemaChange>, migration_available: bool) -> Self {
let breaking_count = changes.iter().filter(|c| c.kind.is_breaking()).count();
let summary = if migration_available {
format!("{} breaking changes, migration available", breaking_count)
} else {
format!(
"{} breaking changes, no migration available",
breaking_count
)
};
Self {
compatible: false,
changes,
migration_required: true,
migration_available,
summary,
}
}
pub fn has_breaking_changes(&self) -> bool {
self.changes.iter().any(|c| c.kind.is_breaking())
}
pub fn breaking_changes(&self) -> Vec<&SchemaChange> {
self.changes
.iter()
.filter(|c| c.kind.is_breaking())
.collect()
}
pub fn non_breaking_changes(&self) -> Vec<&SchemaChange> {
self.changes
.iter()
.filter(|c| !c.kind.is_breaking())
.collect()
}
}
#[derive(Debug, Clone)]
pub struct SchemaChange {
pub kind: ChangeKind,
pub field: String,
pub old_type: Option<String>,
pub new_type: Option<String>,
pub description: String,
}
impl SchemaChange {
pub fn new(kind: ChangeKind, field: impl Into<String>) -> Self {
let field = field.into();
Self {
description: format!("{}: {}", kind.description(), field),
kind,
field,
old_type: None,
new_type: None,
}
}
pub fn with_old_type(mut self, old_type: impl Into<String>) -> Self {
self.old_type = Some(old_type.into());
self
}
pub fn with_new_type(mut self, new_type: impl Into<String>) -> Self {
self.new_type = Some(new_type.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
}
impl std::fmt::Display for SchemaChange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)?;
if let (Some(old), Some(new)) = (&self.old_type, &self.new_type) {
write!(f, " ({} -> {})", old, new)?;
}
Ok(())
}
}
pub struct CompatibilityMatrix {
migrations: Arc<MigrationRegistry>,
}
impl CompatibilityMatrix {
pub fn new(migrations: Arc<MigrationRegistry>) -> Self {
Self { migrations }
}
pub fn check(&self, from: &TypeInfo, to: &TypeInfo) -> CompatibilityReport {
if from.hash != 0 && from.hash == to.hash {
return CompatibilityReport::compatible();
}
let changes = self.detect_changes(from, to);
if changes.is_empty() {
return CompatibilityReport::compatible();
}
let has_breaking = changes.iter().any(|c| c.kind.is_breaking());
if !has_breaking {
return CompatibilityReport {
compatible: true,
changes,
migration_required: false,
migration_available: true,
summary: "Compatible with non-breaking changes".to_string(),
};
}
let migration_available = self.can_migrate(from.hash, to.hash);
CompatibilityReport::incompatible(changes, migration_available)
}
pub fn detect_changes(&self, from: &TypeInfo, to: &TypeInfo) -> Vec<SchemaChange> {
let mut changes = Vec::new();
let from_fields: std::collections::HashMap<&str, _> =
from.fields.iter().map(|f| (f.name.as_str(), f)).collect();
let to_fields: std::collections::HashMap<&str, _> =
to.fields.iter().map(|f| (f.name.as_str(), f)).collect();
for (name, from_field) in &from_fields {
if !to_fields.contains_key(name) {
changes.push(
SchemaChange::new(ChangeKind::RemoveField, *name)
.with_old_type(&from_field.type_name),
);
}
}
for (name, to_field) in &to_fields {
match from_fields.get(name) {
None => {
let kind = if to_field.optional {
ChangeKind::AddOptionalField
} else {
ChangeKind::AddRequiredField
};
changes.push(SchemaChange::new(kind, *name).with_new_type(&to_field.type_name));
}
Some(from_field) => {
if from_field.type_name != to_field.type_name {
changes.push(
SchemaChange::new(ChangeKind::ChangeFieldType, *name)
.with_old_type(&from_field.type_name)
.with_new_type(&to_field.type_name),
);
}
if from_field.optional && !to_field.optional {
changes.push(SchemaChange::new(ChangeKind::MakeRequired, *name));
}
if !from_field.optional && to_field.optional {
changes.push(SchemaChange::new(ChangeKind::MakeOptional, *name));
}
}
}
}
changes
}
pub fn can_migrate(&self, from_hash: u64, to_hash: u64) -> bool {
self.migrations.has_path(from_hash, to_hash)
}
pub fn detect_potential_renames(
&self,
from: &TypeInfo,
to: &TypeInfo,
) -> Vec<(String, String)> {
let mut potential_renames = Vec::new();
let removed: Vec<_> = from
.fields
.iter()
.filter(|f| !to.fields.iter().any(|t| t.name == f.name))
.collect();
let added: Vec<_> = to
.fields
.iter()
.filter(|f| !from.fields.iter().any(|t| t.name == f.name))
.collect();
for removed_field in &removed {
for added_field in &added {
if removed_field.type_name == added_field.type_name
&& removed_field.optional == added_field.optional
{
potential_renames.push((removed_field.name.clone(), added_field.name.clone()));
}
}
}
potential_renames
}
}
impl Default for CompatibilityMatrix {
fn default() -> Self {
Self::new(Arc::new(MigrationRegistry::new()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::FieldInfo;
fn create_v1_schema() -> TypeInfo {
TypeInfo::new("Order", 1).with_hash(100).with_fields(vec![
FieldInfo::new("id", "String"),
FieldInfo::new("amount", "f64"),
FieldInfo::new("status", "String"),
])
}
fn create_v2_schema_compatible() -> TypeInfo {
TypeInfo::new("Order", 2).with_hash(200).with_fields(vec![
FieldInfo::new("id", "String"),
FieldInfo::new("amount", "f64"),
FieldInfo::new("status", "String"),
FieldInfo::new("notes", "String").optional(),
])
}
fn create_v2_schema_breaking() -> TypeInfo {
TypeInfo::new("Order", 2).with_hash(200).with_fields(vec![
FieldInfo::new("id", "String"),
FieldInfo::new("total", "f64"), FieldInfo::new("status", "i32"), ])
}
#[test]
fn identical_schemas() {
let matrix = CompatibilityMatrix::default();
let v1 = create_v1_schema();
let report = matrix.check(&v1, &v1);
assert!(report.compatible);
assert!(report.changes.is_empty());
}
#[test]
fn compatible_with_optional_field() {
let matrix = CompatibilityMatrix::default();
let v1 = create_v1_schema();
let v2 = create_v2_schema_compatible();
let report = matrix.check(&v1, &v2);
assert!(report.compatible);
assert!(!report.migration_required);
assert_eq!(report.changes.len(), 1);
assert_eq!(report.changes[0].kind, ChangeKind::AddOptionalField);
}
#[test]
fn breaking_changes() {
let matrix = CompatibilityMatrix::default();
let v1 = create_v1_schema();
let v2 = create_v2_schema_breaking();
let report = matrix.check(&v1, &v2);
assert!(!report.compatible);
assert!(report.migration_required);
assert!(report.has_breaking_changes());
let breaking = report.breaking_changes();
assert!(!breaking.is_empty());
}
#[test]
fn detect_changes_removed_field() {
let matrix = CompatibilityMatrix::default();
let v1 = TypeInfo::new("Test", 1)
.with_fields(vec![FieldInfo::new("a", "i32"), FieldInfo::new("b", "i32")]);
let v2 = TypeInfo::new("Test", 2).with_fields(vec![FieldInfo::new("a", "i32")]);
let changes = matrix.detect_changes(&v1, &v2);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, ChangeKind::RemoveField);
assert_eq!(changes[0].field, "b");
}
#[test]
fn detect_changes_type_change() {
let matrix = CompatibilityMatrix::default();
let v1 = TypeInfo::new("Test", 1).with_fields(vec![FieldInfo::new("value", "i32")]);
let v2 = TypeInfo::new("Test", 2).with_fields(vec![FieldInfo::new("value", "f64")]);
let changes = matrix.detect_changes(&v1, &v2);
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].kind, ChangeKind::ChangeFieldType);
assert_eq!(changes[0].old_type, Some("i32".to_string()));
assert_eq!(changes[0].new_type, Some("f64".to_string()));
}
#[test]
fn detect_potential_renames() {
let matrix = CompatibilityMatrix::default();
let v1 = TypeInfo::new("Test", 1).with_fields(vec![
FieldInfo::new("old_name", "String"),
FieldInfo::new("other", "i32"),
]);
let v2 = TypeInfo::new("Test", 2).with_fields(vec![
FieldInfo::new("new_name", "String"),
FieldInfo::new("other", "i32"),
]);
let renames = matrix.detect_potential_renames(&v1, &v2);
assert_eq!(renames.len(), 1);
assert_eq!(renames[0], ("old_name".to_string(), "new_name".to_string()));
}
#[test]
fn with_migration_available() {
let migrations = Arc::new(MigrationRegistry::new());
use super::super::migration::Migration;
migrations
.register(Migration::new(
"Order@v1",
100,
"Order@v2",
200,
|_arena, offset| Ok(offset),
))
.unwrap();
let matrix = CompatibilityMatrix::new(migrations);
let v1 = create_v1_schema();
let v2 = create_v2_schema_breaking();
let report = matrix.check(&v1, &v2);
assert!(!report.compatible);
assert!(report.migration_available);
}
}