use std::collections::{BTreeMap, BTreeSet};
use serde_json::Value;
use super::change::{CONSTRAINT_KEYWORDS, ChangeKind, PropertyChange};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SchemaDiff {
pub properties: Vec<PropertyChange>,
pub required_added: Vec<String>,
pub required_removed: Vec<String>,
}
impl SchemaDiff {
#[must_use]
pub fn is_empty(&self) -> bool {
self.properties.is_empty()
&& self.required_added.is_empty()
&& self.required_removed.is_empty()
}
#[must_use]
pub fn is_purely_additive(&self) -> bool {
self.required_added.is_empty()
&& self.required_removed.is_empty()
&& self.properties.iter().all(|c| c.kind == ChangeKind::Added)
}
}
#[must_use]
pub fn diff_schemas(prev: &Value, next: &Value) -> SchemaDiff {
let mut diff = SchemaDiff::default();
walk(prev, next, "", &mut diff);
let before = required_set(prev);
let after = required_set(next);
diff.required_added = after.difference(&before).cloned().collect();
diff.required_removed = before.difference(&after).cloned().collect();
diff.properties.sort_by(|a, b| a.path.cmp(&b.path));
diff
}
fn required_set(schema: &Value) -> BTreeSet<String> {
schema
.get("required")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn walk(prev: &Value, next: &Value, prefix: &str, diff: &mut SchemaDiff) {
let prev_props = properties_of(prev);
let next_props = properties_of(next);
for (name, next_prop) in &next_props {
let path = join(prefix, name);
match prev_props.get(name) {
None => diff.properties.push(PropertyChange {
path,
kind: ChangeKind::Added,
before: String::new(),
after: type_of(next_prop),
}),
Some(prev_prop) => {
compare_property(prev_prop, next_prop, &path, diff);
walk(prev_prop, next_prop, &path, diff);
if let (Some(p), Some(n)) = (prev_prop.get("items"), next_prop.get("items")) {
walk(p, n, &join(&path, "[]"), diff);
}
}
}
}
for name in prev_props.keys() {
if !next_props.contains_key(name) {
diff.properties.push(PropertyChange {
path: join(prefix, name),
kind: ChangeKind::Removed,
before: type_of(&prev_props[name]),
after: String::new(),
});
}
}
}
fn compare_property(prev: &Value, next: &Value, path: &str, diff: &mut SchemaDiff) {
let (before_type, after_type) = (type_of(prev), type_of(next));
if before_type != after_type {
diff.properties.push(PropertyChange {
path: path.to_owned(),
kind: ChangeKind::TypeChanged,
before: before_type,
after: after_type,
});
}
for keyword in CONSTRAINT_KEYWORDS {
let before = prev.get(*keyword);
let after = next.get(*keyword);
if before != after {
diff.properties.push(PropertyChange {
path: path.to_owned(),
kind: ChangeKind::ConstraintChanged,
before: render_constraint(keyword, before),
after: render_constraint(keyword, after),
});
}
}
}
fn properties_of(schema: &Value) -> BTreeMap<String, Value> {
schema
.get("properties")
.and_then(Value::as_object)
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
fn join(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_owned()
} else {
format!("{prefix}/{name}")
}
}
fn type_of(prop: &Value) -> String {
match prop.get("type") {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(a)) => {
let parts: Vec<&str> = a.iter().filter_map(Value::as_str).collect();
parts.join("|")
}
_ => String::new(),
}
}
fn render_constraint(keyword: &str, value: Option<&Value>) -> String {
match value {
None => String::new(),
Some(v) => format!("{keyword}={v}"),
}
}