diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/closed.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/closed.rs index 06fbb04a7f6..ef8cb010971 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/closed.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/closed.rs @@ -78,6 +78,8 @@ pub enum ResolveClosedDataTypeError { ConflictingConstEnumValue(JsonValue, Vec), #[error("The constraint is unsatisfiable: {}", json!(.0))] UnsatisfiableConstraint(ValueConstraints), + #[error("The combined constraints results in an empty `anyOf`")] + EmptyAnyOf, } impl ClosedDataType { @@ -431,10 +433,7 @@ mod tests { ); assert_eq!(number.label, defs.number.label); assert_eq!(number.r#abstract, defs.number.r#abstract); - assert_eq!( - json!(number.all_of), - json!([defs.number.constraints, defs.value.constraints]) - ); + assert_eq!(json!(number.all_of), json!([defs.number.constraints])); } fn check_closed_integer(integer: &ClosedDataType, defs: &DataTypeDefinitions) { @@ -450,10 +449,7 @@ mod tests { ); assert_eq!(integer.label, defs.number.label); assert_eq!(integer.r#abstract, defs.integer.r#abstract); - assert_eq!( - json!(integer.all_of), - json!([defs.integer.constraints, defs.value.constraints]) - ); + assert_eq!(json!(integer.all_of), json!([defs.integer.constraints])); } fn check_closed_unsigned(unsigned: &ClosedDataType, defs: &DataTypeDefinitions) { @@ -469,10 +465,7 @@ mod tests { ); assert_eq!(unsigned.label, defs.number.label); assert_eq!(unsigned.r#abstract, defs.unsigned.r#abstract); - assert_eq!( - json!(unsigned.all_of), - json!([defs.unsigned.constraints, defs.value.constraints]) - ); + assert_eq!(json!(unsigned.all_of), json!([defs.unsigned.constraints])); } fn check_closed_unsigned_int(unsigned_int: &ClosedDataType, defs: &DataTypeDefinitions) { @@ -496,8 +489,7 @@ mod tests { "minimum": 0.0, "maximum": 4_294_967_295.0, "multipleOf": 1.0, - }, - defs.value.constraints + } ]) ); } @@ -515,10 +507,7 @@ mod tests { ); assert_eq!(small.label, defs.small.label); assert_eq!(small.r#abstract, defs.small.r#abstract); - assert_eq!( - json!(small.all_of), - json!([defs.small.constraints, defs.value.constraints]) - ); + assert_eq!(json!(small.all_of), json!([defs.small.constraints])); } fn check_closed_unsigned_small_int( @@ -551,8 +540,7 @@ mod tests { "minimum": 0.0, "maximum": 100.0, "multipleOf": 1.0, - }, - defs.value.constraints + } ]) ); } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs index 57c1c4d2938..f6c080c1a18 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/any_of.rs @@ -1,12 +1,13 @@ -use error_stack::{Report, ReportSink, ResultExt}; +use error_stack::{Report, ReportSink, ResultExt, TryReportIteratorExt, bail}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use crate::schema::{ - ConstraintError, SingleValueSchema, + ConstraintError, SingleValueSchema, ValueLabel, data_type::{ closed::ResolveClosedDataTypeError, - constraint::{Constraint, ConstraintValidator}, + constraint::{Constraint, ConstraintValidator, ValueConstraints}, }, }; @@ -21,14 +22,93 @@ pub struct AnyOfConstraints { pub any_of: Vec, } +impl From for ValueConstraints { + fn from(mut constraints: AnyOfConstraints) -> Self { + if constraints.any_of.len() == 1 + && constraints.any_of[0].description.is_none() + && constraints.any_of[0].label.is_empty() + { + Self::Typed(constraints.any_of.remove(0).constraints) + } else { + Self::AnyOf(constraints) + } + } +} + impl Constraint for AnyOfConstraints { fn intersection( self, other: Self, ) -> Result<(Self, Option), Report> { - // TODO: Implement folding for anyOf constraints - // see https://linear.app/hash/issue/H-3430/implement-folding-for-anyof-constraints - Ok((self, Some(other))) + let mut combined_constraints = Vec::new(); + let mut remainders = Vec::new(); + let mut errors = Vec::new(); + + for (lhs, rhs) in self + .any_of + .clone() + .into_iter() + .cartesian_product(other.any_of.clone()) + { + let (constraints, remainder) = match lhs.constraints.combine(rhs.constraints) { + Ok((constraints, remainder)) => (constraints, remainder), + Err(error) => { + errors.push(Err(error)); + continue; + } + }; + + let (description, label) = if lhs.description.is_none() && lhs.label.is_empty() { + (rhs.description, rhs.label) + } else { + (lhs.description, lhs.label) + }; + combined_constraints.push(SingleValueSchema { + description, + label, + constraints, + }); + if let Some(remainder) = remainder { + remainders.push(remainder); + } + } + + match combined_constraints.len() { + 0 => { + // We now properly capture errors to return it to the caller. + let _: Vec<()> = errors + .into_iter() + .try_collect_reports() + .change_context(ResolveClosedDataTypeError::EmptyAnyOf)?; + bail!(ResolveClosedDataTypeError::EmptyAnyOf); + } + 1 => Ok(( + Self { + any_of: combined_constraints, + }, + remainders.pop().map(|constraints| Self { + any_of: vec![SingleValueSchema { + constraints, + description: None, + label: ValueLabel::default(), + }], + }), + )), + _ => { + if remainders.is_empty() { + Ok(( + Self { + any_of: combined_constraints, + }, + None, + )) + } else { + // Not possible to combine the constraints, we keep the input as it is + // for now + Ok((self, Some(other))) + } + } + } } } @@ -56,3 +136,318 @@ impl ConstraintValidator for AnyOfConstraints { status.finish().change_context(ConstraintError::AnyOf) } } + +#[cfg(test)] +mod tests { + use serde_json::{from_value, json}; + + use crate::schema::data_type::{ + closed::ResolveClosedDataTypeError, + constraint::tests::{check_schema_intersection, check_schema_intersection_error}, + }; + + #[test] + fn intersect_min_max() { + check_schema_intersection( + [ + json!({ + "type": "number", + "minimum": 5.0, + "maximum": 10.0, + }), + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 7.0, + }, + { + "type": "number", + "maximum": 12.0, + }, + ], + }), + ], + [json!({ + "anyOf": [ + { + "type": "number", + "minimum": 7.0, + "maximum": 10.0, + }, + { + "type": "number", + "minimum": 5.0, + "maximum": 10.0, + }, + ], + })], + ); + } + + #[test] + fn intersect_single_variant() { + check_schema_intersection( + [ + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 7.0, + "maximum": 12.0, + }, + ], + }), + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 5.0, + "maximum": 10.0, + }, + ], + }), + ], + [json!( + { + "type": "number", + "minimum": 7.0, + "maximum": 10.0, + } + )], + ); + } + + #[test] + fn intersect_single_variant_metadata() { + check_schema_intersection( + [ + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 7.0, + "maximum": 12.0, + }, + ], + }), + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 5.0, + "maximum": 10.0, + "description": "A number between 5 and 10", + }, + ], + }), + ], + [json!({ + "anyOf": [ + { + "type": "number", + "minimum": 7.0, + "maximum": 10.0, + "description": "A number between 5 and 10", + }, + ], + })], + ); + } + + #[test] + fn intersect_single_variant_with_remainder() { + check_schema_intersection( + [ + json!({ + "anyOf": [ + { + "type": "number", + "multipleOf": 2.0, + }, + ], + }), + json!({ + "anyOf": [ + { + "type": "number", + "multipleOf": 3.0, + }, + ], + }), + ], + [ + json!( + { + "type": "number", + "multipleOf": 2.0, + }), + json!({ + "type": "number", + "multipleOf": 3.0, + }), + ], + ); + } + + #[test] + fn intersect_multi_variant_with_remainder() { + let schemas = [ + json!({ + "anyOf": [ + { + "type": "number", + "multipleOf": 2.0, + }, + { + "type": "number", + "multipleOf": 7.0, + }, + ], + }), + json!({ + "anyOf": [ + { + "type": "number", + "multipleOf": 3.0, + }, + { + "type": "number", + "multipleOf": 5.0, + }, + ], + }), + ]; + check_schema_intersection(schemas.clone(), schemas); + } + + #[test] + fn intersect_multi_variant_without_remainder() { + check_schema_intersection( + [ + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 2.0, + "description": "A1", + }, + { + "type": "number", + "minimum": 5.0, + }, + ], + }), + json!({ + "anyOf": [ + { + "type": "number", + "maximum": 10.0, + "description": "B1", + }, + { + "type": "number", + "maximum": 15.0, + "description": "B2", + }, + ], + }), + ], + [json!({ + "anyOf": [ + { + "type": "number", + "minimum": 2.0, + "maximum": 10.0, + "description": "A1", + }, + { + "type": "number", + "minimum": 2.0, + "maximum": 15.0, + "description": "A1", + }, + { + "type": "number", + "minimum": 5.0, + "maximum": 10.0, + "description": "B1", + }, + { + "type": "number", + "minimum": 5.0, + "maximum": 15.0, + "description": "B2", + }, + ], + })], + ); + } + + #[test] + fn intersect_results_in_empty() { + check_schema_intersection_error( + [ + json!({ + "anyOf": [ + { + "type": "number", + "minimum": 3.0, + }, + { + "type": "number", + "minimum": 4.0, + }, + ], + }), + json!({ + "anyOf": [ + { + "type": "number", + "maximum": 1.0, + }, + { + "type": "number", + "maximum": 2.0, + }, + ], + }), + ], + [ + ResolveClosedDataTypeError::EmptyAnyOf, + ResolveClosedDataTypeError::UnsatisfiableConstraint( + from_value(json!({ + "type": "number", + "minimum": 3.0, + "maximum": 1.0, + })) + .expect("Failed to parse schema"), + ), + ResolveClosedDataTypeError::UnsatisfiableConstraint( + from_value(json!({ + "type": "number", + "minimum": 4.0, + "maximum": 1.0, + })) + .expect("Failed to parse schema"), + ), + ResolveClosedDataTypeError::UnsatisfiableConstraint( + from_value(json!({ + "type": "number", + "minimum": 3.0, + "maximum": 2.0, + })) + .expect("Failed to parse schema"), + ), + ResolveClosedDataTypeError::UnsatisfiableConstraint( + from_value(json!({ + "type": "number", + "minimum": 4.0, + "maximum": 2.0, + })) + .expect("Failed to parse schema"), + ), + ], + ); + } +} diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs index a9590759e4e..0c5c838ec21 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/mod.rs @@ -132,18 +132,30 @@ impl Constraint for ValueConstraints { .intersection(rhs) .map(|(lhs, rhs)| (Self::Typed(lhs), rhs.map(Self::Typed))), (Self::AnyOf(lhs), Self::Typed(rhs)) => { - // TODO: Implement folding for anyOf constraints - // see https://linear.app/hash/issue/H-3430/implement-folding-for-anyof-constraints - Ok((Self::AnyOf(lhs), Some(Self::Typed(rhs)))) + let rhs = AnyOfConstraints { + any_of: vec![SingleValueSchema { + constraints: rhs, + description: None, + label: ValueLabel::default(), + }], + }; + lhs.combine(rhs) + .map(|(lhs, rhs)| (Self::from(lhs), rhs.map(Self::from))) } (Self::Typed(lhs), Self::AnyOf(rhs)) => { - // TODO: Implement folding for anyOf constraints - // see https://linear.app/hash/issue/H-3430/implement-folding-for-anyof-constraints - Ok((Self::Typed(lhs), Some(Self::AnyOf(rhs)))) + let lhs = AnyOfConstraints { + any_of: vec![SingleValueSchema { + constraints: lhs, + description: None, + label: ValueLabel::default(), + }], + }; + lhs.combine(rhs) + .map(|(lhs, rhs)| (Self::from(lhs), rhs.map(Self::from))) } (Self::AnyOf(lhs), Self::AnyOf(rhs)) => lhs .intersection(rhs) - .map(|(lhs, rhs)| (Self::AnyOf(lhs), rhs.map(Self::AnyOf))), + .map(|(lhs, rhs)| (Self::from(lhs), rhs.map(Self::from))), } } }