use serde_json::Value;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct SchemaChange {
#[allow(dead_code)]
pub version: u64,
pub timestamp: u64,
pub schema: Value,
pub is_breaking: bool,
}
impl SchemaChange {
pub fn new(version: u64, timestamp: u64, schema: Value, is_breaking: bool) -> Self {
Self {
version,
timestamp,
schema,
is_breaking,
}
}
}
pub fn is_breaking_change(previous_changes: &[SchemaChange], new_schema: &Value) -> bool {
if previous_changes.is_empty() {
return false;
}
let last_schema = match previous_changes.last() {
Some(change) => &change.schema,
None => return false,
};
detect_breaking_schema_changes(last_schema, new_schema)
}
pub fn detect_breaking_schema_changes(old_schema: &Value, new_schema: &Value) -> bool {
if let (Some(old_fields), Some(new_fields)) =
(old_schema.get("fields"), new_schema.get("fields"))
{
if let (Some(old_fields_array), Some(new_fields_array)) =
(old_fields.as_array(), new_fields.as_array())
{
let old_field_names: HashSet<String> = old_fields_array
.iter()
.filter_map(|f| {
f.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect();
let new_field_names: HashSet<String> = new_fields_array
.iter()
.filter_map(|f| {
f.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect();
if !old_field_names.is_subset(&new_field_names) {
return true;
}
if has_type_or_nullability_changes(old_fields_array, new_fields_array) {
return true;
}
}
}
false
}
fn has_type_or_nullability_changes(old_fields: &[Value], new_fields: &[Value]) -> bool {
for old_field in old_fields {
if let Some(field_name) = old_field.get("name").and_then(|n| n.as_str()) {
if let Some(new_field) = new_fields
.iter()
.find(|f| f.get("name").and_then(|n| n.as_str()) == Some(field_name))
{
let old_type = old_field.get("type").and_then(|t| t.as_str());
let new_type = new_field.get("type").and_then(|t| t.as_str());
if old_type != new_type {
return true;
}
if has_nullability_breaking_change(old_field, new_field) {
return true;
}
}
}
}
false
}
fn has_nullability_breaking_change(old_field: &Value, new_field: &Value) -> bool {
if let (Some(old_nullable), Some(new_nullable)) = (
old_field.get("nullable").and_then(|n| n.as_bool()),
new_field.get("nullable").and_then(|n| n.as_bool()),
) {
if !old_nullable && new_nullable {
return true;
}
}
if let (Some(old_required), Some(new_required)) = (
old_field.get("required").and_then(|r| r.as_bool()),
new_field.get("required").and_then(|r| r.as_bool()),
) {
if !old_required && new_required {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_schema_change_new() {
let schema = json!({"fields": []});
let change = SchemaChange::new(1, 1000, schema.clone(), false);
assert_eq!(change.version, 1);
assert_eq!(change.timestamp, 1000);
assert!(!change.is_breaking);
}
#[test]
fn test_is_breaking_change_empty_previous() {
let new_schema = json!({"fields": [{"name": "id", "type": "int"}]});
assert!(!is_breaking_change(&[], &new_schema));
}
#[test]
fn test_detect_breaking_change_column_removal() {
let old_schema = json!({
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"}
]
});
let new_schema = json!({
"fields": [
{"name": "id", "type": "int"}
]
});
assert!(detect_breaking_schema_changes(&old_schema, &new_schema));
}
#[test]
fn test_detect_breaking_change_type_change() {
let old_schema = json!({
"fields": [{"name": "id", "type": "int"}]
});
let new_schema = json!({
"fields": [{"name": "id", "type": "string"}]
});
assert!(detect_breaking_schema_changes(&old_schema, &new_schema));
}
#[test]
fn test_detect_no_breaking_change_add_column() {
let old_schema = json!({
"fields": [{"name": "id", "type": "int"}]
});
let new_schema = json!({
"fields": [
{"name": "id", "type": "int"},
{"name": "name", "type": "string"}
]
});
assert!(!detect_breaking_schema_changes(&old_schema, &new_schema));
}
}