use panproto_schema::Protocol;
use serde::{Deserialize, Serialize};
use crate::diff::{ConstraintChange, SchemaDiff};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompatReport {
pub breaking: Vec<BreakingChange>,
pub non_breaking: Vec<NonBreakingChange>,
pub compatible: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BreakingChange {
RemovedVertex {
vertex_id: String,
},
RemovedEdge {
src: String,
tgt: String,
kind: String,
name: Option<String>,
},
KindChanged {
vertex_id: String,
old_kind: String,
new_kind: String,
},
ConstraintTightened {
vertex_id: String,
sort: String,
old_value: String,
new_value: String,
},
ConstraintAdded {
vertex_id: String,
sort: String,
value: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum NonBreakingChange {
AddedVertex {
vertex_id: String,
},
AddedEdge {
src: String,
tgt: String,
kind: String,
name: Option<String>,
},
ConstraintRelaxed {
vertex_id: String,
sort: String,
old_value: String,
new_value: String,
},
ConstraintRemoved {
vertex_id: String,
sort: String,
},
}
#[must_use]
pub fn classify(diff: &SchemaDiff, protocol: &Protocol) -> CompatReport {
let mut breaking = Vec::new();
let mut non_breaking = Vec::new();
for v in &diff.removed_vertices {
breaking.push(BreakingChange::RemovedVertex {
vertex_id: v.clone(),
});
}
for v in &diff.added_vertices {
non_breaking.push(NonBreakingChange::AddedVertex {
vertex_id: v.clone(),
});
}
for e in &diff.removed_edges {
if protocol.find_edge_rule(&e.kind).is_some() {
breaking.push(BreakingChange::RemovedEdge {
src: e.src.clone(),
tgt: e.tgt.clone(),
kind: e.kind.clone(),
name: e.name.clone(),
});
} else {
non_breaking.push(NonBreakingChange::AddedEdge {
src: e.src.clone(),
tgt: e.tgt.clone(),
kind: e.kind.clone(),
name: e.name.clone(),
});
}
}
for e in &diff.added_edges {
non_breaking.push(NonBreakingChange::AddedEdge {
src: e.src.clone(),
tgt: e.tgt.clone(),
kind: e.kind.clone(),
name: e.name.clone(),
});
}
for kc in &diff.kind_changes {
breaking.push(BreakingChange::KindChanged {
vertex_id: kc.vertex_id.clone(),
old_kind: kc.old_kind.clone(),
new_kind: kc.new_kind.clone(),
});
}
for (vid, cdiff) in &diff.modified_constraints {
for c in &cdiff.added {
if protocol.constraint_sorts.iter().any(|s| s == &c.sort) {
breaking.push(BreakingChange::ConstraintAdded {
vertex_id: vid.clone(),
sort: c.sort.clone(),
value: c.value.clone(),
});
}
}
for c in &cdiff.removed {
if protocol.constraint_sorts.iter().any(|s| s == &c.sort) {
non_breaking.push(NonBreakingChange::ConstraintRemoved {
vertex_id: vid.clone(),
sort: c.sort.clone(),
});
}
}
for change in &cdiff.changed {
if protocol.constraint_sorts.iter().any(|s| s == &change.sort) {
classify_constraint_change(vid, change, &mut breaking, &mut non_breaking);
}
}
}
let compatible = breaking.is_empty();
CompatReport {
breaking,
non_breaking,
compatible,
}
}
fn classify_constraint_change(
vertex_id: &str,
change: &ConstraintChange,
breaking: &mut Vec<BreakingChange>,
non_breaking: &mut Vec<NonBreakingChange>,
) {
let is_tightened = is_constraint_tightened(&change.sort, &change.old_value, &change.new_value);
if is_tightened {
breaking.push(BreakingChange::ConstraintTightened {
vertex_id: vertex_id.to_string(),
sort: change.sort.clone(),
old_value: change.old_value.clone(),
new_value: change.new_value.clone(),
});
} else {
non_breaking.push(NonBreakingChange::ConstraintRelaxed {
vertex_id: vertex_id.to_string(),
sort: change.sort.clone(),
old_value: change.old_value.clone(),
new_value: change.new_value.clone(),
});
}
}
fn is_constraint_tightened(sort: &str, old_val: &str, new_val: &str) -> bool {
match sort {
"maxLength" | "maxSize" | "maximum" | "maxGraphemes" => {
let old_n: Result<i64, _> = old_val.parse();
let new_n: Result<i64, _> = new_val.parse();
if let (Ok(o), Ok(n)) = (old_n, new_n) {
return n < o;
}
true
}
"minLength" | "minimum" => {
let old_n: Result<i64, _> = old_val.parse();
let new_n: Result<i64, _> = new_val.parse();
if let (Ok(o), Ok(n)) = (old_n, new_n) {
return n > o;
}
true
}
_ => {
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::{ConstraintDiff, KindChange};
use panproto_schema::{Edge, EdgeRule};
fn test_protocol() -> Protocol {
Protocol {
name: "test".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![EdgeRule {
edge_kind: "prop".into(),
src_kinds: vec!["object".into()],
tgt_kinds: vec![],
}],
obj_kinds: vec!["object".into()],
constraint_sorts: vec!["maxLength".into()],
}
}
#[test]
fn classify_removed_required_field_as_breaking() {
let diff = SchemaDiff {
removed_vertices: vec!["body.text".into()],
removed_edges: vec![Edge {
src: "body".into(),
tgt: "body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(!report.compatible, "removing a vertex should be breaking");
assert_eq!(report.breaking.len(), 2); }
#[test]
fn classify_added_optional_field_as_non_breaking() {
let diff = SchemaDiff {
added_vertices: vec!["body.newField".into()],
added_edges: vec![Edge {
src: "body".into(),
tgt: "body.newField".into(),
kind: "prop".into(),
name: Some("newField".into()),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(report.compatible, "adding a vertex should be non-breaking");
assert_eq!(report.non_breaking.len(), 2); assert!(report.breaking.is_empty());
}
#[test]
fn classify_constraint_tightening_as_breaking() {
let diff = SchemaDiff {
modified_constraints: std::iter::once((
"body.text".into(),
ConstraintDiff {
added: vec![],
removed: vec![],
changed: vec![crate::diff::ConstraintChange {
sort: "maxLength".into(),
old_value: "3000".into(),
new_value: "300".into(),
}],
},
))
.collect(),
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(
!report.compatible,
"tightening maxLength should be breaking"
);
assert!(
report
.breaking
.iter()
.any(|b| matches!(b, BreakingChange::ConstraintTightened { .. }))
);
}
#[test]
fn classify_constraint_relaxing_as_non_breaking() {
let diff = SchemaDiff {
modified_constraints: std::iter::once((
"body.text".into(),
ConstraintDiff {
added: vec![],
removed: vec![],
changed: vec![crate::diff::ConstraintChange {
sort: "maxLength".into(),
old_value: "300".into(),
new_value: "3000".into(),
}],
},
))
.collect(),
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(
report.compatible,
"relaxing maxLength should be non-breaking"
);
assert!(
report
.non_breaking
.iter()
.any(|nb| matches!(nb, NonBreakingChange::ConstraintRelaxed { .. }))
);
}
#[test]
fn classify_kind_change_as_breaking() {
let diff = SchemaDiff {
kind_changes: vec![KindChange {
vertex_id: "x".into(),
old_kind: "string".into(),
new_kind: "integer".into(),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(!report.compatible, "kind change should be breaking");
}
}