From c13bac272f9dc5977f912472806635818f2373ce Mon Sep 17 00:00:00 2001 From: Tim Diekmann <21277928+TimDiekmann@users.noreply.github.com> Date: Wed, 19 Feb 2025 14:23:16 +0100 Subject: [PATCH] Preserve order for `enum` data type constraints (#6486) --- .../src/schema/data_type/constraint/number.rs | 460 ++++-------------- .../src/schema/data_type/constraint/string.rs | 405 ++++----------- .../rust/src/schema/data_type/mod.rs | 45 +- .../rust/src/schema/data_type/validation.rs | 11 +- libs/@local/codec/src/serde/mod.rs | 1 + libs/@local/codec/src/serde/unique_vec.rs | 41 ++ .../hash-isomorphic-utils/src/data-types.ts | 4 +- 7 files changed, 221 insertions(+), 746 deletions(-) create mode 100644 libs/@local/codec/src/serde/unique_vec.rs diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs index 1956b95b7d7..a69b1ac358d 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/number.rs @@ -66,13 +66,10 @@ pub enum NumberTypeTag { #[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] pub enum NumberSchema { Constrained(NumberConstraints), - Const { - #[cfg_attr(target_arch = "wasm32", tsify(type = "number"))] - r#const: Real, - }, Enum { #[cfg_attr(target_arch = "wasm32", tsify(type = "[number, ...number[]]"))] - r#enum: BTreeSet, + #[serde(deserialize_with = "hash_codec::serde::unique_vec::btree")] + r#enum: Vec, }, } @@ -96,7 +93,6 @@ fn float_multiple_of(lhs: &Real, rhs: &Real) -> bool { } impl Constraint for NumberSchema { - #[expect(clippy::too_many_lines)] fn intersection( self, other: Self, @@ -105,21 +101,6 @@ impl Constraint for NumberSchema { (Self::Constrained(lhs), Self::Constrained(rhs)) => lhs .intersection(rhs) .map(|(lhs, rhs)| (Self::Constrained(lhs), rhs.map(Self::Constrained)))?, - (Self::Const { r#const }, Self::Constrained(constraints)) - | (Self::Constrained(constraints), Self::Const { r#const }) => { - constraints - .validate_value(&r#const) - .change_context_lazy(|| { - ResolveClosedDataTypeError::UnsatisfiedConstraint( - Value::Number(r#const.clone()), - ValueConstraints::Typed(SingleValueConstraints::Number( - Self::Constrained(constraints), - )), - ) - })?; - - (Self::Const { r#const }, None) - } (Self::Enum { r#enum }, Self::Constrained(constraints)) | (Self::Constrained(constraints), Self::Enum { r#enum }) => { // We use the fast way to filter the values that pass the constraints and collect @@ -129,99 +110,64 @@ impl Constraint for NumberSchema { .iter() .filter(|&value| constraints.is_valid(value)) .cloned() - .collect::>(); - - match passed.len() { - 0 => { - // We now properly capture errors to return it to the caller. - let () = r#enum - .into_iter() - .map(|value| { - constraints.validate_value(&value).change_context( - ResolveClosedDataTypeError::UnsatisfiedEnumConstraintVariant( - Value::Number(value), - ), - ) - }) - .try_collect_reports() - .change_context_lazy(|| { - ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( - ValueConstraints::Typed(SingleValueConstraints::Number( - Self::Constrained(constraints.clone()), - )), - ) - })?; - - // This should only happen if `enum` is malformed and has no values. This - // should be caught by the schema validation, however, if this still happens - // we return an error as validating empty enum will always fail. - bail!(ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( - ValueConstraints::Typed(SingleValueConstraints::Number( - Self::Constrained(constraints), - )), - )) - } - 1 => ( - Self::Const { - r#const: passed.into_iter().next().unwrap_or_else(|| { - unreachable!( - "we have exactly one value in the enum that passed the \ - constraints" - ) - }), - }, - None, - ), - _ => (Self::Enum { r#enum: passed }, None), - } - } - (Self::Const { r#const: lhs }, Self::Const { r#const: rhs }) => { - if float_eq(&lhs, &rhs) { - (Self::Const { r#const: lhs }, None) - } else { - bail!(ResolveClosedDataTypeError::ConflictingConstValues( - Value::Number(lhs), - Value::Number(rhs), + .collect::>(); + + if passed.is_empty() { + // We now properly capture errors to return it to the caller. + let () = r#enum + .into_iter() + .map(|value| { + constraints.validate_value(&value).change_context( + ResolveClosedDataTypeError::UnsatisfiedEnumConstraintVariant( + Value::Number(value), + ), + ) + }) + .try_collect_reports() + .change_context_lazy(|| { + ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( + ValueConstraints::Typed(SingleValueConstraints::Number( + Self::Constrained(constraints.clone()), + )), + ) + })?; + + // This should only happen if `enum` is malformed and has no values. This + // should be caught by the schema validation, however, if this still happens + // we return an error as validating empty enum will always fail. + bail!(ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( + ValueConstraints::Typed(SingleValueConstraints::Number(Self::Constrained( + constraints + ))), )) } + + (Self::Enum { r#enum: passed }, None) } (Self::Enum { r#enum: lhs }, Self::Enum { r#enum: rhs }) => { - let intersection = lhs.intersection(&rhs).cloned().collect::>(); + // We use a `BTreeSet` to find the actual intersection of the two enums. It's not + // required to clone the values. + let lhs_set = lhs.iter().collect::>(); + let rhs_set = rhs.iter().collect::>(); + let intersection = lhs_set.intersection(&rhs_set).collect::>(); - match intersection.len() { - 0 => bail!(ResolveClosedDataTypeError::ConflictingEnumValues( + ensure!( + !intersection.is_empty(), + ResolveClosedDataTypeError::ConflictingEnumValues( lhs.into_iter().map(Value::Number).collect(), rhs.into_iter().map(Value::Number).collect(), - )), - 1 => ( - Self::Const { - r#const: intersection.into_iter().next().unwrap_or_else(|| { - unreachable!( - "we have exactly least one value in the enum intersection" - ) - }), - }, - None, - ), - _ => ( - Self::Enum { - r#enum: intersection, - }, - None, - ), - } - } - (Self::Const { r#const }, Self::Enum { r#enum }) - | (Self::Enum { r#enum }, Self::Const { r#const }) => { - ensure!( - r#enum.iter().any(|value| float_eq(value, &r#const)), - ResolveClosedDataTypeError::ConflictingConstEnumValue( - Value::Number(r#const), - r#enum.into_iter().map(Value::Number).collect(), ) ); - (Self::Const { r#const }, None) + ( + Self::Enum { + r#enum: lhs + .into_iter() + .filter(|value| rhs.contains(value)) + .collect(), + }, + None, + ) } }) } @@ -256,7 +202,6 @@ impl ConstraintValidator for NumberSchema { fn is_valid(&self, value: &Real) -> bool { match self { Self::Constrained(constraints) => constraints.is_valid(value), - Self::Const { r#const } => float_eq(value, r#const), Self::Enum { r#enum } => r#enum.iter().any(|expected| float_eq(value, expected)), } } @@ -266,14 +211,6 @@ impl ConstraintValidator for NumberSchema { Self::Constrained(constraints) => constraints .validate_value(value) .change_context(ConstraintError::ValueConstraint)?, - Self::Const { r#const } => { - if !float_eq(value, r#const) { - bail!(ConstraintError::InvalidConstValue { - actual: Value::Number(value.clone()), - expected: Value::Number(r#const.clone()), - }); - } - } Self::Enum { r#enum } => { ensure!( r#enum.iter().any(|expected| float_eq(value, expected)), @@ -454,7 +391,7 @@ mod tests { ValueConstraints, tests::{ check_constraints, check_constraints_error, check_schema_intersection, - check_schema_intersection_error, intersect_schemas, read_schema, + check_schema_intersection_error, read_schema, }, }, }, @@ -711,24 +648,6 @@ mod tests { ); } - #[test] - fn constant() { - let number_schema = read_schema(&json!({ - "type": "number", - "const": 50.0, - })); - - check_constraints(&number_schema, json!(50.0)); - check_constraints_error( - &number_schema, - json!(10.0), - [ConstraintError::InvalidConstValue { - actual: Value::Number(Real::from(10)), - expected: Value::Number(Real::from(50)), - }], - ); - } - #[test] fn enumeration() { let number_schema = read_schema(&json!({ @@ -766,24 +685,21 @@ mod tests { #[test] fn mixed() { - from_value::(json!({ - "type": "number", - "const": 50, - "minimum": 0, - })) - .expect_err("Deserialized number schema with mixed properties"); from_value::(json!({ "type": "number", "enum": [50], "minimum": 0, })) .expect_err("Deserialized number schema with mixed properties"); + } + + #[test] + fn duplicate_enum_values() { from_value::(json!({ "type": "number", - "const": 50, - "enum": [50], + "enum": [50, 50], })) - .expect_err("Deserialized number schema with mixed properties"); + .expect_err("Deserialized number schema with duplicate enum values"); } #[test] @@ -1012,125 +928,29 @@ mod tests { } #[test] - fn intersect_const_const_same() { + fn intersect_enum_enum_compatible_multi() { check_schema_intersection( [ json!({ "type": "number", - "const": 5.0, + "enum": [15.0, 5.0, 10.0], }), json!({ "type": "number", - "const": 5.0, - }), - ], - [json!({ - "type": "number", - "const": 5.0, - })], - ); - } - - #[test] - fn intersect_const_const_different() { - check_schema_intersection_error( - [ - json!({ - "type": "number", - "const": 5.0, - }), - json!({ - "type": "number", - "const": 10.0, - }), - ], - [ResolveClosedDataTypeError::ConflictingConstValues( - Value::Number(Real::from(5)), - Value::Number(Real::from(10)), - )], - ); - } - - #[test] - fn intersect_const_enum_compatible() { - check_schema_intersection( - [ - json!({ - "type": "number", - "const": 5.0, + "enum": [5.0, 15.0, 20.0], }), json!({ "type": "number", - "enum": [5.0, 10.0], + "enum": [0.0, 5.0, 15.0], }), ], [json!({ "type": "number", - "const": 5.0, + "enum": [15.0, 5.0], })], ); } - #[test] - fn intersect_const_enum_incompatible() { - let report = intersect_schemas([ - json!({ - "type": "number", - "const": 5.0, - }), - json!({ - "type": "number", - "enum": [10.0, 15.0], - }), - ]) - .expect_err("Intersected invalid schemas"); - - let Some(ResolveClosedDataTypeError::ConflictingConstEnumValue(lhs, rhs)) = - report.downcast_ref::() - else { - panic!("Expected conflicting const-enum values error"); - }; - assert_eq!(lhs, &Value::Number(Real::from(5))); - - assert_eq!(rhs.len(), 2); - assert!(rhs.contains(&Value::Number(Real::from(10)))); - assert!(rhs.contains(&Value::Number(Real::from(15)))); - } - - #[test] - fn intersect_enum_enum_compatible_multi() { - let intersection = intersect_schemas([ - json!({ - "type": "number", - "enum": [5.0, 10.0, 15.0], - }), - json!({ - "type": "number", - "enum": [5.0, 15.0, 20.0], - }), - json!({ - "type": "number", - "enum": [0.0, 5.0, 15.0], - }), - ]) - .expect("Intersected invalid constraints") - .into_iter() - .map(|schema| { - from_value::(schema).expect("Failed to deserialize schema") - }) - .collect::>(); - - // We need to manually check the intersection because the order of the enum values is not - // guaranteed. - assert_eq!(intersection.len(), 1); - let SingleValueConstraints::Number(NumberSchema::Enum { r#enum }) = &intersection[0] else { - panic!("Expected string enum schema"); - }; - assert_eq!(r#enum.len(), 2); - assert!(r#enum.contains(&Real::from(5))); - assert!(r#enum.contains(&Real::from(15))); - } - #[test] fn intersect_enum_enum_compatible_single() { check_schema_intersection( @@ -1150,125 +970,27 @@ mod tests { ], [json!({ "type": "number", - "const": 5.0, + "enum": [5.0], })], ); } #[test] fn intersect_enum_enum_incompatible() { - let report = intersect_schemas([ - json!({ - "type": "number", - "enum": [5.0, 10.0, 15.0], - }), - json!({ - "type": "number", - "enum": [20.0, 25.0, 30.0], - }), - ]) - .expect_err("Intersected invalid schemas"); - - let Some(ResolveClosedDataTypeError::ConflictingEnumValues(lhs, rhs)) = - report.downcast_ref::() - else { - panic!("Expected conflicting enum values error"); - }; - assert_eq!(lhs.len(), 3); - assert!(lhs.contains(&Value::Number(Real::from(5)))); - assert!(lhs.contains(&Value::Number(Real::from(10)))); - assert!(lhs.contains(&Value::Number(Real::from(15)))); - - assert_eq!(rhs.len(), 3); - assert!(rhs.contains(&Value::Number(Real::from(20)))); - assert!(rhs.contains(&Value::Number(Real::from(25)))); - assert!(rhs.contains(&Value::Number(Real::from(30)))); - } - - #[test] - fn intersect_const_constraint_compatible() { - check_schema_intersection( - [ - json!({ - "type": "number", - "const": 5.0, - }), - json!({ - "type": "number", - "minimum": 0.0, - "maximum": 10.0, - }), - ], - [json!({ - "type": "number", - "const": 5.0, - })], - ); - - check_schema_intersection( - [ - json!({ - "type": "number", - "minimum": 0.0, - "maximum": 10.0, - }), - json!({ - "type": "number", - "const": 5.0, - }), - ], - [json!({ - "type": "number", - "const": 5.0, - })], - ); - } - - #[test] - fn intersect_const_constraint_incompatible() { check_schema_intersection_error( [ json!({ "type": "number", - "const": 5.0, - }), - json!({ - "type": "number", - "minimum": 10.0, - "maximum": 15.0, - }), - ], - [ResolveClosedDataTypeError::UnsatisfiedConstraint( - Value::Number(Real::from(5)), - from_value(json!({ - "type": "number", - "minimum": 10.0, - "maximum": 15.0, - })) - .expect("Failed to parse schema"), - )], - ); - - check_schema_intersection_error( - [ - json!({ - "type": "number", - "minimum": 10.0, - "maximum": 15.0, + "enum": [5.0, 10.0, 15.0], }), json!({ "type": "number", - "const": 5.0, + "enum": [20.0, 25.0, 30.0], }), ], - [ResolveClosedDataTypeError::UnsatisfiedConstraint( - Value::Number(Real::from(5)), - from_value(json!({ - "type": "number", - "minimum": 10.0, - "maximum": 15.0, - })) - .expect("Failed to parse schema"), + [ResolveClosedDataTypeError::ConflictingEnumValues( + from_value(json!([5.0, 10.0, 15.0])).expect("Failed to parse enum"), + from_value(json!([20.0, 25.0, 30.0])).expect("Failed to parse enum"), )], ); } @@ -1295,39 +1017,29 @@ mod tests { ], [json!({ "type": "number", - "const": 5.0, + "enum": [5.0], })], ); } #[test] fn intersect_enum_constraint_compatible_multi() { - let intersection = intersect_schemas([ - json!({ - "type": "number", - "enum": [5.0, 10.0, 15.0], - }), - json!({ + check_schema_intersection( + [ + json!({ + "type": "number", + "enum": [5.0, 10.0, 15.0], + }), + json!({ + "type": "number", + "minimum": 10.0, + }), + ], + [json!({ "type": "number", - "minimum": 10.0, - }), - ]) - .expect("Intersected invalid constraints") - .into_iter() - .map(|schema| { - from_value::(schema).expect("Failed to deserialize schema") - }) - .collect::>(); - - // We need to manually check the intersection because the order of the enum values is not - // guaranteed. - assert_eq!(intersection.len(), 1); - let SingleValueConstraints::Number(NumberSchema::Enum { r#enum }) = &intersection[0] else { - panic!("Expected string enum schema"); - }; - assert_eq!(r#enum.len(), 2); - assert!(r#enum.contains(&Real::from(10))); - assert!(r#enum.contains(&Real::from(15))); + "enum": [10.0, 15.0], + })], + ); } #[test] @@ -1432,7 +1144,7 @@ mod tests { }), json!({ "type": "number", - "const": 10.0, + "enum": [10.0, 20.0], }), json!({ "type": "number", @@ -1450,7 +1162,7 @@ mod tests { ], [json!({ "type": "number", - "const": 10.0, + "enum": [10.0], })], ); } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs index e61fd78e70f..5611b75f6c5 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/constraint/string.rs @@ -220,17 +220,14 @@ pub enum StringTypeTag { #[serde(untagged, rename_all = "camelCase", deny_unknown_fields)] pub enum StringSchema { Constrained(StringConstraints), - Const { - r#const: String, - }, Enum { #[cfg_attr(target_arch = "wasm32", tsify(type = "[string, ...string[]]"))] - r#enum: HashSet, + #[serde(deserialize_with = "hash_codec::serde::unique_vec::hashed")] + r#enum: Vec, }, } impl Constraint for StringSchema { - #[expect(clippy::too_many_lines)] fn intersection( self, other: Self, @@ -239,21 +236,6 @@ impl Constraint for StringSchema { (Self::Constrained(lhs), Self::Constrained(rhs)) => lhs .intersection(rhs) .map(|(lhs, rhs)| (Self::Constrained(lhs), rhs.map(Self::Constrained)))?, - (Self::Const { r#const }, Self::Constrained(constraints)) - | (Self::Constrained(constraints), Self::Const { r#const }) => { - constraints - .validate_value(&r#const) - .change_context_lazy(|| { - ResolveClosedDataTypeError::UnsatisfiedConstraint( - Value::String(r#const.clone()), - ValueConstraints::Typed(SingleValueConstraints::String( - Self::Constrained(constraints), - )), - ) - })?; - - (Self::Const { r#const }, None) - } (Self::Enum { r#enum }, Self::Constrained(constraints)) | (Self::Constrained(constraints), Self::Enum { r#enum }) => { // We use the fast way to filter the values that pass the constraints and collect @@ -263,99 +245,64 @@ impl Constraint for StringSchema { .iter() .filter(|&value| constraints.is_valid(value)) .cloned() - .collect::>(); - - match passed.len() { - 0 => { - // We now properly capture errors to return it to the caller. - let () = r#enum - .into_iter() - .map(|value| { - constraints.validate_value(&value).change_context( - ResolveClosedDataTypeError::UnsatisfiedEnumConstraintVariant( - Value::String(value), - ), - ) - }) - .try_collect_reports() - .change_context_lazy(|| { - ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( - ValueConstraints::Typed(SingleValueConstraints::String( - Self::Constrained(constraints.clone()), - )), - ) - })?; - - // This should only happen if `enum` is malformed and has no values. This - // should be caught by the schema validation, however, if this still happens - // we return an error as validating empty enum will always fail. - bail!(ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( - ValueConstraints::Typed(SingleValueConstraints::String( - Self::Constrained(constraints), - )), - )) - } - 1 => ( - Self::Const { - r#const: passed.into_iter().next().unwrap_or_else(|| { - unreachable!( - "we have exactly one value in the enum that passed the \ - constraints" - ) - }), - }, - None, - ), - _ => (Self::Enum { r#enum: passed }, None), - } - } - (Self::Const { r#const: lhs }, Self::Const { r#const: rhs }) => { - if lhs == rhs { - (Self::Const { r#const: lhs }, None) - } else { - bail!(ResolveClosedDataTypeError::ConflictingConstValues( - Value::String(lhs), - Value::String(rhs), + .collect::>(); + + if passed.is_empty() { + // We now properly capture errors to return it to the caller. + let () = r#enum + .into_iter() + .map(|value| { + constraints.validate_value(&value).change_context( + ResolveClosedDataTypeError::UnsatisfiedEnumConstraintVariant( + Value::String(value), + ), + ) + }) + .try_collect_reports() + .change_context_lazy(|| { + ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( + ValueConstraints::Typed(SingleValueConstraints::String( + Self::Constrained(constraints.clone()), + )), + ) + })?; + + // This should only happen if `enum` is malformed and has no values. This + // should be caught by the schema validation, however, if this still happens + // we return an error as validating empty enum will always fail. + bail!(ResolveClosedDataTypeError::UnsatisfiedEnumConstraint( + ValueConstraints::Typed(SingleValueConstraints::String(Self::Constrained( + constraints + ))), )) } + + (Self::Enum { r#enum: passed }, None) } (Self::Enum { r#enum: lhs }, Self::Enum { r#enum: rhs }) => { - let intersection = lhs.intersection(&rhs).cloned().collect::>(); + // We use a `HashSet` to find the actual intersection of the two enums. It's not + // required to clone the values. + let lhs_set = lhs.iter().collect::>(); + let rhs_set = rhs.iter().collect::>(); + let intersection = lhs_set.intersection(&rhs_set).collect::>(); - match intersection.len() { - 0 => bail!(ResolveClosedDataTypeError::ConflictingEnumValues( + ensure!( + !intersection.is_empty(), + ResolveClosedDataTypeError::ConflictingEnumValues( lhs.into_iter().map(Value::String).collect(), rhs.into_iter().map(Value::String).collect(), - )), - 1 => ( - Self::Const { - r#const: intersection.into_iter().next().unwrap_or_else(|| { - unreachable!( - "we have exactly least one value in the enum intersection" - ) - }), - }, - None, - ), - _ => ( - Self::Enum { - r#enum: intersection, - }, - None, - ), - } - } - (Self::Const { r#const }, Self::Enum { r#enum }) - | (Self::Enum { r#enum }, Self::Const { r#const }) => { - ensure!( - r#enum.contains(&r#const), - ResolveClosedDataTypeError::ConflictingConstEnumValue( - Value::String(r#const), - r#enum.into_iter().map(Value::String).collect(), ) ); - (Self::Const { r#const }, None) + ( + Self::Enum { + r#enum: lhs + .into_iter() + .filter(|value| rhs.contains(value)) + .collect(), + }, + None, + ) } }) } @@ -390,8 +337,7 @@ impl ConstraintValidator for StringSchema { fn is_valid(&self, value: &str) -> bool { match self { Self::Constrained(constraints) => constraints.is_valid(value), - Self::Const { r#const } => value == r#const, - Self::Enum { r#enum } => r#enum.contains(value), + Self::Enum { r#enum } => r#enum.iter().any(|item| item == value), } } @@ -400,16 +346,8 @@ impl ConstraintValidator for StringSchema { Self::Constrained(constraints) => constraints .validate_value(value) .change_context(ConstraintError::ValueConstraint)?, - Self::Const { r#const } => { - if value != *r#const { - bail!(ConstraintError::InvalidConstValue { - actual: Value::String(value.to_owned()), - expected: Value::String(r#const.clone()), - }); - } - } Self::Enum { r#enum } => { - if !r#enum.contains(value) { + if !r#enum.iter().any(|item| item == value) { bail!(ConstraintError::InvalidEnumValue { actual: Value::String(value.to_owned()), expected: r#enum.iter().cloned().map(Value::String).collect(), @@ -579,12 +517,12 @@ mod tests { use crate::{ Value, schema::{ - JsonSchemaValueType, SingleValueConstraints, + JsonSchemaValueType, data_type::constraint::{ ValueConstraints, tests::{ check_constraints, check_constraints_error, check_schema_intersection, - check_schema_intersection_error, intersect_schemas, read_schema, + check_schema_intersection_error, read_schema, }, }, }, @@ -643,24 +581,6 @@ mod tests { ); } - #[test] - fn constant() { - let string_schema = read_schema(&json!({ - "type": "string", - "const": "foo", - })); - - check_constraints(&string_schema, json!("foo")); - check_constraints_error( - &string_schema, - json!("bar"), - [ConstraintError::InvalidConstValue { - actual: Value::String("bar".to_owned()), - expected: Value::String("foo".to_owned()), - }], - ); - } - #[test] fn enumeration() { let string_schema = read_schema(&json!({ @@ -718,6 +638,15 @@ mod tests { .expect_err("Deserialized string schema with mixed properties"); } + #[test] + fn duplicate_enum_values() { + from_value::(json!({ + "type": "string", + "enum": ["foo", "foo"], + })) + .expect_err("Deserialized string schema with duplicate enum values"); + } + #[test] fn intersect_default() { check_schema_intersection( @@ -944,125 +873,29 @@ mod tests { } #[test] - fn intersect_const_const_same() { + fn intersect_enum_enum_compatible_multi() { check_schema_intersection( [ json!({ "type": "string", - "const": "foo", - }), - json!({ - "type": "string", - "const": "foo", + "enum": ["foo", "bar", "baz"], }), - ], - [json!({ - "type": "string", - "const": "foo", - })], - ); - } - - #[test] - fn intersect_const_const_different() { - check_schema_intersection_error( - [ json!({ "type": "string", - "const": "foo", + "enum": ["foo", "baz", "qux"], }), json!({ "type": "string", - "const": "bar", - }), - ], - [ResolveClosedDataTypeError::ConflictingConstValues( - Value::String("foo".to_owned()), - Value::String("bar".to_owned()), - )], - ); - } - - #[test] - fn intersect_const_enum_compatible() { - check_schema_intersection( - [ - json!({ - "type": "string", - "const": "foo", - }), - json!({ - "type": "string", - "enum": ["foo", "bar"], + "enum": ["foo", "bar", "qux", "baz"], }), ], [json!({ "type": "string", - "const": "foo", + "enum": ["foo", "baz"], })], ); } - #[test] - fn intersect_const_enum_incompatible() { - let report = intersect_schemas([ - json!({ - "type": "string", - "const": "foo", - }), - json!({ - "type": "string", - "enum": ["bar", "baz"], - }), - ]) - .expect_err("Intersected invalid schemas"); - - let Some(ResolveClosedDataTypeError::ConflictingConstEnumValue(lhs, rhs)) = - report.downcast_ref::() - else { - panic!("Expected conflicting const-enum values error"); - }; - assert_eq!(lhs, &Value::String("foo".to_owned())); - - assert_eq!(rhs.len(), 2); - assert!(rhs.contains(&Value::String("bar".to_owned()))); - assert!(rhs.contains(&Value::String("baz".to_owned()))); - } - - #[test] - fn intersect_enum_enum_compatible_multi() { - let intersection = intersect_schemas([ - json!({ - "type": "string", - "enum": ["foo", "bar", "baz"], - }), - json!({ - "type": "string", - "enum": ["foo", "baz", "qux"], - }), - json!({ - "type": "string", - "enum": ["foo", "bar", "qux", "baz"], - }), - ]) - .expect("Intersected invalid constraints") - .into_iter() - .map(|schema| { - from_value::(schema).expect("Failed to deserialize schema") - }) - .collect::>(); - - // We need to manually check the intersection because the order of the enum values is not - // guaranteed. - assert_eq!(intersection.len(), 1); - let SingleValueConstraints::String(StringSchema::Enum { r#enum }) = &intersection[0] else { - panic!("Expected string enum schema"); - }; - assert_eq!(r#enum.len(), 2); - assert!(r#enum.contains("foo")); - assert!(r#enum.contains("baz")); - } - #[test] fn intersect_enum_enum_compatible_single() { check_schema_intersection( @@ -1082,133 +915,71 @@ mod tests { ], [json!({ "type": "string", - "const": "foo", + "enum": ["foo"], })], ); } #[test] fn intersect_enum_enum_incompatible() { - let report = intersect_schemas([ - json!({ - "type": "string", - "enum": ["foo", "bar"], - }), - json!({ - "type": "string", - "enum": ["baz", "qux"], - }), - ]) - .expect_err("Intersected invalid schemas"); - - let Some(ResolveClosedDataTypeError::ConflictingEnumValues(lhs, rhs)) = - report.downcast_ref::() - else { - panic!("Expected conflicting enum values error"); - }; - assert_eq!(lhs.len(), 2); - assert!(lhs.contains(&Value::String("foo".to_owned()))); - assert!(lhs.contains(&Value::String("bar".to_owned()))); - - assert_eq!(rhs.len(), 2); - assert!(rhs.contains(&Value::String("baz".to_owned()))); - assert!(rhs.contains(&Value::String("qux".to_owned()))); - } - - #[test] - fn intersect_const_constraint_compatible() { - check_schema_intersection( + check_schema_intersection_error( [ json!({ "type": "string", - "const": "foo", + "enum": ["foo", "bar"], }), json!({ "type": "string", - "minLength": 3, + "enum": ["baz", "qux"], }), ], - [json!({ - "type": "string", - "const": "foo", - })], + [ResolveClosedDataTypeError::ConflictingEnumValues( + from_value(json!(["foo", "bar"])).expect("Failed to parse enum"), + from_value(json!(["baz", "qux"])).expect("Failed to parse enum"), + )], ); } #[test] - fn intersect_const_constraint_incompatible() { - check_schema_intersection_error( + fn intersect_enum_constraint_compatible_single() { + check_schema_intersection( [ json!({ "type": "string", - "const": "foo", + "enum": ["foo", "foobar"], }), json!({ "type": "string", "minLength": 5, }), ], - [ResolveClosedDataTypeError::UnsatisfiedConstraint( - Value::String("foo".to_owned()), - from_value(json!({ - "type": "string", - "minLength": 5, - })) - .expect("Failed to parse schema"), - )], + [json!({ + "type": "string", + "enum": ["foobar"], + })], ); } #[test] - fn intersect_enum_constraint_compatible_single() { + fn intersect_enum_constraint_compatible_multi() { check_schema_intersection( [ json!({ "type": "string", - "enum": ["foo", "foobar"], + "enum": ["foo", "foobar", "bar"], }), json!({ "type": "string", - "minLength": 5, + "maxLength": 3, }), ], [json!({ "type": "string", - "const": "foobar", + "enum": ["foo", "bar"], })], ); } - #[test] - fn intersect_enum_constraint_compatible_multi() { - let intersection = intersect_schemas([ - json!({ - "type": "string", - "enum": ["foo", "foobar", "bar"], - }), - json!({ - "type": "string", - "maxLength": 3, - }), - ]) - .expect("Intersected invalid constraints") - .into_iter() - .map(|schema| { - from_value::(schema).expect("Failed to deserialize schema") - }) - .collect::>(); - - // We need to manually check the intersection because the order of the enum values is not - // guaranteed. - assert_eq!(intersection.len(), 1); - let SingleValueConstraints::String(StringSchema::Enum { r#enum }) = &intersection[0] else { - panic!("Expected string enum schema"); - }; - assert_eq!(r#enum.len(), 2); - assert!(r#enum.contains("foo")); - assert!(r#enum.contains("bar")); - } - #[test] fn intersect_enum_constraint_incompatible() { check_schema_intersection_error( @@ -1313,7 +1084,7 @@ mod tests { ], [json!({ "type": "string", - "const": "foo", + "enum": ["foo"], })], ); } diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs index d39ea49527e..57733c30cf5 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/mod.rs @@ -112,7 +112,6 @@ pub struct ValueSchemaMetadata { mod raw { use alloc::collections::BTreeSet; - use std::collections::HashSet; use hash_codec::numeric::Real; use serde::{Deserialize, Serialize}; @@ -176,21 +175,13 @@ mod raw { #[serde(flatten)] constraints: NumberConstraints, }, - NumberConst { - r#type: NumberTypeTag, - #[serde(flatten)] - base: DataTypeBase, - #[serde(flatten)] - metadata: ValueSchemaMetadata, - r#const: Real, - }, NumberEnum { r#type: NumberTypeTag, #[serde(flatten)] base: DataTypeBase, #[serde(flatten)] metadata: ValueSchemaMetadata, - r#enum: BTreeSet, + r#enum: Vec, }, String { r#type: StringTypeTag, @@ -201,21 +192,13 @@ mod raw { #[serde(flatten)] constraints: StringConstraints, }, - StringConst { - r#type: StringTypeTag, - #[serde(flatten)] - base: DataTypeBase, - #[serde(flatten)] - metadata: ValueSchemaMetadata, - r#const: String, - }, StringEnum { r#type: StringTypeTag, #[serde(flatten)] base: DataTypeBase, #[serde(flatten)] metadata: ValueSchemaMetadata, - r#enum: HashSet, + r#enum: Vec, }, Object { r#type: ObjectTypeTag, @@ -314,18 +297,6 @@ mod raw { NumberSchema::Constrained(constraints), )), ), - DataType::NumberConst { - r#type: _, - base, - metadata, - r#const, - } => ( - base, - metadata, - ValueConstraints::Typed(SingleValueConstraints::Number(NumberSchema::Const { - r#const, - })), - ), DataType::NumberEnum { r#type: _, base, @@ -350,18 +321,6 @@ mod raw { StringSchema::Constrained(constraints), )), ), - DataType::StringConst { - r#type: _, - base, - metadata, - r#const, - } => ( - base, - metadata, - ValueConstraints::Typed(SingleValueConstraints::String(StringSchema::Const { - r#const, - })), - ), DataType::StringEnum { r#type: _, base, diff --git a/libs/@blockprotocol/type-system/rust/src/schema/data_type/validation.rs b/libs/@blockprotocol/type-system/rust/src/schema/data_type/validation.rs index c4dbf0b0d8a..1e20e8b0e7a 100644 --- a/libs/@blockprotocol/type-system/rust/src/schema/data_type/validation.rs +++ b/libs/@blockprotocol/type-system/rust/src/schema/data_type/validation.rs @@ -4,22 +4,13 @@ use std::{collections::HashSet, sync::LazyLock}; use thiserror::Error; use crate::{ - Valid, Validator, Value, + Valid, Validator, schema::{ClosedDataType, DataType, DataTypeReference}, url::VersionedUrl, }; #[derive(Debug, Error)] pub enum ValidateDataTypeError { - #[error("Enum values are not compatible with `const` value")] - EnumValuesNotCompatibleWithConst { - const_value: Value, - enum_values: Vec, - }, - #[error("Missing data type `{data_type_id}`")] - MissingDataType { data_type_id: VersionedUrl }, - #[error("Cyclic data type reference detected for type `{data_type_id}`")] - CyclicDataTypeReference { data_type_id: VersionedUrl }, #[error("A data type requires a parent specified in `allOf`")] MissingParent, #[error("Only primitive data types can inherit from the value data type")] diff --git a/libs/@local/codec/src/serde/mod.rs b/libs/@local/codec/src/serde/mod.rs index 69479bd4529..45722bd0e9d 100644 --- a/libs/@local/codec/src/serde/mod.rs +++ b/libs/@local/codec/src/serde/mod.rs @@ -5,6 +5,7 @@ pub mod constant; pub mod string_hash_map; +pub mod unique_vec; mod size_hint; diff --git a/libs/@local/codec/src/serde/unique_vec.rs b/libs/@local/codec/src/serde/unique_vec.rs new file mode 100644 index 00000000000..937db443f0e --- /dev/null +++ b/libs/@local/codec/src/serde/unique_vec.rs @@ -0,0 +1,41 @@ +use alloc::collections::BTreeSet; +use core::hash::Hash; +use std::collections::HashSet; + +use serde::{Deserialize, Deserializer, de}; + +/// Deserialize a vector of values that are unique. +/// +/// Uses a [`HashSet`] to ensure that the values are unique. +/// +/// # Errors +/// +/// - If the vector contains duplicate values. +pub fn hashed<'de, D, T: Hash + Eq + Deserialize<'de>>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let vec = Vec::deserialize(deserializer)?; + if vec.len() != vec.iter().collect::>().len() { + return Err(de::Error::custom("duplicate value")); + } + Ok(vec) +} + +/// Deserialize a vector of values that are unique. +/// +/// Uses a [`BTreeSet`] to ensure that the values are unique. +/// +/// # Errors +/// +/// - If the vector contains duplicate values. +pub fn btree<'de, D, T: Ord + Deserialize<'de>>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let vec = Vec::deserialize(deserializer)?; + if vec.len() != vec.iter().collect::>().len() { + return Err(de::Error::custom("duplicate value")); + } + Ok(vec) +} diff --git a/libs/@local/hash-isomorphic-utils/src/data-types.ts b/libs/@local/hash-isomorphic-utils/src/data-types.ts index c6be632b686..4780183038f 100644 --- a/libs/@local/hash-isomorphic-utils/src/data-types.ts +++ b/libs/@local/hash-isomorphic-utils/src/data-types.ts @@ -248,7 +248,7 @@ const transformConstraint = ( const { description, label, type } = constraint; if (type === "string") { - if ("enum" in constraint || "const" in constraint) { + if ("enum" in constraint) { return constraint; } @@ -258,7 +258,7 @@ const transformConstraint = ( }; } if (type === "number") { - if ("enum" in constraint || "const" in constraint) { + if ("enum" in constraint) { return constraint; }