use graph_storage_sdk::models::{EffectiveTraits, SchemaDiagnostic, TraitChange, TypeChangeState};
use gts::schema_evolution::CompatibilityVerdict;
use gts::store::GtsStore;
use serde_json::Value;
use crate::domain::error::DomainError;
#[derive(Clone, Debug)]
pub struct Comparison {
pub backward: CompatibilityVerdict,
pub forward: CompatibilityVerdict,
pub diagnostics: Vec<SchemaDiagnostic>,
pub levels_not_evolvable_in_place: Vec<String>,
}
impl Comparison {
#[must_use]
pub fn state(&self) -> TypeChangeState {
match self.backward {
CompatibilityVerdict::Compatible => TypeChangeState::Compatible,
CompatibilityVerdict::Incompatible => TypeChangeState::Incompatible,
CompatibilityVerdict::Unknown => TypeChangeState::Undecidable,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Decision {
Accept,
Revalidate,
Migrate,
Refuse,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Offered {
Nothing,
Rows,
Steps,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asked {
pub update: bool,
pub offered: Offered,
}
#[must_use]
pub fn offered(migration: bool, revalidate: bool) -> Offered {
if migration {
Offered::Steps
} else if revalidate {
Offered::Rows
} else {
Offered::Nothing
}
}
#[must_use]
pub fn decide(state: TypeChangeState, asked: Asked) -> Decision {
match state {
TypeChangeState::New | TypeChangeState::Unchanged => {
if asked.offered == Offered::Steps {
Decision::Refuse
} else {
Decision::Accept
}
}
_ if !asked.update => Decision::Refuse,
_ if asked.offered == Offered::Steps => Decision::Migrate,
TypeChangeState::Compatible => Decision::Accept,
TypeChangeState::Incompatible | TypeChangeState::Undecidable => {
if asked.offered == Offered::Rows {
Decision::Revalidate
} else {
Decision::Refuse
}
}
}
}
pub fn compare(
old: &Value,
new: &Value,
chain: impl IntoIterator<Item = (String, Value)>,
) -> Result<Comparison, DomainError> {
let mut store = GtsStore::new();
for (type_id, schema) in chain {
store.register_schema(&type_id, &schema).map_err(|error| {
DomainError::internal(format!(
"ancestor `{type_id}` is not a usable schema reference: {error}"
))
})?;
}
let comparison = store.compare_documents(old, new).map_err(|error| {
DomainError::invalid(format!("the two definitions cannot be compared: {error}"))
})?;
Ok(Comparison {
backward: comparison.backward_compatibility(),
forward: comparison.forward_compatibility(),
diagnostics: comparison
.backward_diagnostics
.iter()
.map(|diagnostic| SchemaDiagnostic {
location: diagnostic.path.clone(),
finding: finding_name(diagnostic.finding),
message: diagnostic.detail.clone(),
})
.collect(),
levels_not_evolvable_in_place: comparison
.levels_not_evolvable_in_place()
.into_iter()
.map(|level| level.path.clone())
.collect(),
})
}
fn finding_name(finding: gts::schema_evolution::CompatibilityFinding) -> String {
use gts::schema_evolution::CompatibilityFinding as F;
match finding {
F::PropertyAdded => "property_added",
F::PropertyRemoved => "property_removed",
F::RequiredChanged => "required_changed",
F::ContentModelChanged => "content_model_changed",
F::TypeChanged => "type_changed",
F::EnumChanged => "enum_changed",
F::BoundChanged => "bound_changed",
F::NarrowingConstraintChanged => "narrowing_constraint_changed",
F::ConstraintChanged => "constraint_changed",
F::DialectChanged => "dialect_changed",
F::NotProvable => "not_provable",
}
.to_owned()
}
#[must_use]
pub fn traits_diff(old: &EffectiveTraits, new: &EffectiveTraits) -> Vec<TraitChange> {
let mut changes = Vec::new();
let mut compare_lists = |name: &str, old: &[String], new: &[String]| {
let added: Vec<String> = new
.iter()
.filter(|path| !old.contains(path))
.cloned()
.collect();
let removed: Vec<String> = old
.iter()
.filter(|path| !new.contains(path))
.cloned()
.collect();
if !added.is_empty() || !removed.is_empty() {
changes.push(TraitChange {
trait_name: name.to_owned(),
added,
removed,
});
}
};
compare_lists("index", &old.index, &new.index);
compare_lists(
"full_text_search",
&old.full_text_search,
&new.full_text_search,
);
compare_lists("vector_search", &old.vector_search, &new.vector_search);
compare_lists("src_types", &old.src_types, &new.src_types);
compare_lists("dst_types", &old.dst_types, &new.dst_types);
if old.family != new.family {
changes.push(TraitChange {
trait_name: "family".to_owned(),
added: new.family.clone().into_iter().collect(),
removed: old.family.clone().into_iter().collect(),
});
}
changes
}
#[must_use]
pub fn recompute_needed(changes: &[TraitChange]) -> (bool, bool) {
let touched = |name: &str| changes.iter().any(|change| change.trait_name == name);
(touched("full_text_search"), touched("vector_search"))
}
#[must_use]
pub fn refusal_reason(
type_id: &str,
state: TypeChangeState,
diagnostics: &[SchemaDiagnostic],
limit: usize,
) -> String {
let lead = match state {
TypeChangeState::Undecidable => format!(
"type `{type_id}` is registered and the candidate cannot be proven backward \
compatible with it"
),
_ => format!(
"type `{type_id}` is registered and the candidate is not backward compatible \
with it"
),
};
let mut listed: Vec<String> = diagnostics
.iter()
.take(limit)
.map(|diagnostic| format!("{} {}", diagnostic.location, diagnostic.message))
.collect();
if diagnostics.len() > limit {
listed.push(format!("and {} more", diagnostics.len() - limit));
}
if listed.is_empty() {
return lead;
}
format!("{lead}: {}", listed.join("; "))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn leaf(properties: &Value, closed: bool, required: &Value) -> Value {
let mut payload = json!({ "type": "object", "properties": properties.clone() });
if closed {
payload["additionalProperties"] = json!(false);
}
if required != &json!([]) {
payload["required"] = required.clone();
}
json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"allOf": [
{ "$ref": "gts://gts.acme.gs._.evolution_base.v1~" },
{ "type": "object", "properties": { "payload": payload } }
]
})
}
fn chain() -> Vec<(String, Value)> {
vec![(
"gts.acme.gs._.evolution_base.v1~".to_owned(),
json!({
"$id": "gts://gts.acme.gs._.evolution_base.v1~",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": { "node_key": { "type": "string" }, "payload": { "type": "object" } }
}),
)]
}
fn state_of(old: &Value, new: &Value) -> TypeChangeState {
compare(old, new, chain())
.expect("the chain resolves")
.state()
}
#[test]
fn an_added_optional_property_is_compatible_at_a_closed_level() {
let old = leaf(
&json!({ "key": { "type": "string" } }),
true,
&json!(["key"]),
);
let new = leaf(
&json!({ "key": { "type": "string" }, "owner": { "type": "string" } }),
true,
&json!(["key"]),
);
assert_eq!(state_of(&old, &new), TypeChangeState::Compatible);
}
#[test]
fn the_same_property_added_at_an_open_level_is_incompatible() {
let old = leaf(
&json!({ "key": { "type": "string" } }),
false,
&json!(["key"]),
);
let new = leaf(
&json!({ "key": { "type": "string" }, "owner": { "type": "string" } }),
false,
&json!(["key"]),
);
let comparison = compare(&old, &new, chain()).expect("the chain resolves");
assert_eq!(comparison.state(), TypeChangeState::Incompatible);
let diagnostic = comparison
.diagnostics
.first()
.expect("an incompatible verdict must carry its evidence");
assert_eq!(diagnostic.location, "$.payload");
assert_eq!(diagnostic.finding, "property_added");
}
#[test]
fn a_widened_enum_is_compatible_and_a_narrowed_one_is_not() {
let with = |values: Value| {
leaf(
&json!({ "status": { "type": "string", "enum": values } }),
true,
&json!([]),
)
};
let narrow = with(json!(["proposed", "approved"]));
let wide = with(json!(["proposed", "approved", "blocked"]));
assert_eq!(state_of(&narrow, &wide), TypeChangeState::Compatible);
assert_eq!(state_of(&wide, &narrow), TypeChangeState::Incompatible);
}
#[test]
fn a_renamed_property_is_incompatible_in_both_shapes() {
for closed in [true, false] {
let old = leaf(
&json!({ "priority": { "type": "string" } }),
closed,
&json!([]),
);
let new = leaf(
&json!({ "urgency": { "type": "string" } }),
closed,
&json!([]),
);
assert_eq!(
state_of(&old, &new),
TypeChangeState::Incompatible,
"closed={closed}"
);
}
}
#[test]
fn a_new_required_property_is_incompatible() {
let old = leaf(
&json!({ "key": { "type": "string" } }),
true,
&json!(["key"]),
);
let new = leaf(
&json!({ "key": { "type": "string" }, "owner": { "type": "string" } }),
true,
&json!(["key", "owner"]),
);
let comparison = compare(&old, &new, chain()).expect("the chain resolves");
assert_eq!(comparison.state(), TypeChangeState::Incompatible);
assert!(
comparison
.diagnostics
.iter()
.any(|d| d.finding == "required_changed"),
"{:?}",
comparison.diagnostics
);
}
#[test]
fn forward_is_reported_separately() {
let old = leaf(&json!({ "key": { "type": "string" } }), true, &json!([]));
let new = leaf(
&json!({ "key": { "type": "string" }, "owner": { "type": "string" } }),
true,
&json!([]),
);
let comparison = compare(&old, &new, chain()).expect("the chain resolves");
assert!(comparison.backward.is_compatible());
assert!(comparison.forward.is_incompatible());
}
fn asked(update: bool, revalidate: bool, migration: bool) -> Asked {
Asked {
update,
offered: if migration {
Offered::Steps
} else if revalidate {
Offered::Rows
} else {
Offered::Nothing
},
}
}
#[test]
fn the_rule_refuses_everything_it_cannot_prove_unless_asked_to_revalidate() {
for state in [TypeChangeState::Incompatible, TypeChangeState::Undecidable] {
assert_eq!(decide(state, asked(false, false, false)), Decision::Refuse);
assert_eq!(decide(state, asked(true, false, false)), Decision::Refuse);
assert_eq!(
decide(state, asked(true, true, false)),
Decision::Revalidate
);
}
assert_eq!(
decide(TypeChangeState::Compatible, asked(true, false, false)),
Decision::Accept
);
assert_eq!(
decide(TypeChangeState::Compatible, asked(false, false, false)),
Decision::Refuse
);
assert_eq!(
decide(TypeChangeState::Unchanged, asked(false, false, false)),
Decision::Accept
);
}
#[test]
fn a_migration_takes_over_wherever_the_schema_moves() {
for state in [
TypeChangeState::Compatible,
TypeChangeState::Incompatible,
TypeChangeState::Undecidable,
] {
assert_eq!(
decide(state, asked(true, false, true)),
Decision::Migrate,
"{state:?}"
);
assert_eq!(decide(state, asked(false, false, true)), Decision::Refuse);
}
for state in [TypeChangeState::New, TypeChangeState::Unchanged] {
assert_eq!(decide(state, asked(true, true, true)), Decision::Refuse);
}
}
#[test]
fn a_trait_diff_names_the_paths_that_moved() {
let old = EffectiveTraits {
index: vec!["/payload/priority".to_owned()],
full_text_search: vec!["/name".to_owned()],
..EffectiveTraits::default()
};
let new = EffectiveTraits {
index: vec!["/payload/urgency".to_owned()],
full_text_search: vec!["/name".to_owned()],
..EffectiveTraits::default()
};
let changes = traits_diff(&old, &new);
assert_eq!(changes.len(), 1, "{changes:?}");
assert_eq!(changes[0].trait_name, "index");
assert_eq!(changes[0].added, vec!["/payload/urgency".to_owned()]);
assert_eq!(changes[0].removed, vec!["/payload/priority".to_owned()]);
assert_eq!(recompute_needed(&changes), (false, false));
}
#[test]
fn a_changed_search_trait_asks_for_a_recompute() {
let old = EffectiveTraits::default();
let new = EffectiveTraits {
full_text_search: vec!["/payload/statement".to_owned()],
vector_search: vec!["/payload/statement".to_owned()],
..EffectiveTraits::default()
};
assert_eq!(recompute_needed(&traits_diff(&old, &new)), (true, true));
}
#[test]
fn a_refusal_names_the_offending_locations() {
let reason = refusal_reason(
"gts.acme.gs._.evolution_base.v1~acme.gs._.thing.v1~",
TypeChangeState::Incompatible,
&[
SchemaDiagnostic {
location: "$.payload".to_owned(),
finding: "required_changed".to_owned(),
message: "adds required properties: [\"owner\"]".to_owned(),
},
SchemaDiagnostic {
location: "$.payload.status".to_owned(),
finding: "enum_changed".to_owned(),
message: "removes enum values".to_owned(),
},
],
1,
);
assert!(reason.contains("$.payload adds required"), "{reason}");
assert!(reason.contains("and 1 more"), "{reason}");
}
}