From fe57b80987e1975786006476d80da88c1a7ff772 Mon Sep 17 00:00:00 2001 From: Rowan Hart Date: Mon, 5 Aug 2024 21:18:57 -0700 Subject: [PATCH] Add support for patternProperties in particular circumstances (#637) --- typify-impl/src/convert.rs | 81 ++++++++++ typify-impl/tests/vega.out | 163 +++++++++++++++++++-- typify/tests/schemas/property-pattern.json | 20 +++ typify/tests/schemas/property-pattern.rs | 135 +++++++++++++++++ 4 files changed, 383 insertions(+), 16 deletions(-) create mode 100644 typify/tests/schemas/property-pattern.json create mode 100644 typify/tests/schemas/property-pattern.rs diff --git a/typify-impl/src/convert.rs b/typify-impl/src/convert.rs index 553cd963..6f638684 100644 --- a/typify-impl/src/convert.rs +++ b/typify-impl/src/convert.rs @@ -1132,6 +1132,49 @@ impl TypeSpace { Ok((TypeEntryDetails::Unit.into(), metadata)) } + /// Determine whether a schema's property name validation constraints can be handled + fn can_handle_pattern_properties(validation: &ObjectValidation) -> bool { + if !validation.required.is_empty() { + return false; + } + + if !validation.properties.is_empty() { + return false; + } + + // Ensure we have at least one pattern property and all pattern property + // schemas are the same + let Some(first_schema) = validation.pattern_properties.values().next() else { + return false; + }; + + if !validation + .pattern_properties + .values() + .all(|schema| schema == first_schema) + { + return false; + } + + // Ensure any additional properties are a false or null schema + if validation.additional_properties.as_ref().map(AsRef::as_ref) == Some(&Schema::Bool(true)) + || matches!( + validation.additional_properties.as_ref().map(AsRef::as_ref), + Some(&Schema::Object(_)) + ) + { + return false; + } + + // Ensure there are no additional property names constraints, to avoid a + // collision between different types of constraints interacting unexpectedly + if validation.property_names.is_some() { + return false; + } + + true + } + fn convert_object<'a>( &mut self, type_name: Name, @@ -1163,6 +1206,44 @@ impl TypeSpace { )?; Ok((type_entry, metadata)) } + + Some(validation) if Self::can_handle_pattern_properties(validation) => { + let pattern = validation + .pattern_properties + .keys() + .cloned() + .collect::>() + .join("|"); + + // Construct a schema to use for property name validation + let property_names = Some(Box::new(Schema::Object(SchemaObject { + string: Some(Box::new(StringValidation { + max_length: None, + min_length: None, + pattern: Some(pattern), + })), + ..Default::default() + }))); + + // Construct schema to use for property value validation + let additional_properties = Some(Box::new( + validation + .pattern_properties + .values() + .next() + .cloned() + .unwrap_or_else(|| unreachable!("pattern_properties cannot be empty here")), + )); + + let type_entry = self.make_map( + type_name.into_option(), + &property_names, + &additional_properties, + )?; + + Ok((type_entry, metadata)) + } + None => { let type_entry = self.make_map(type_name.into_option(), &None, &None)?; Ok((type_entry, metadata)) diff --git a/typify-impl/tests/vega.out b/typify-impl/tests/vega.out index 935ad139..043bf703 100644 --- a/typify-impl/tests/vega.out +++ b/typify-impl/tests/vega.out @@ -29966,13 +29966,28 @@ impl ToString for Element { #[doc = r" ```"] #[doc = r" "] #[derive(Clone, Debug, serde :: Deserialize, serde :: Serialize)] -#[serde(deny_unknown_fields)] -pub struct Encode {} +pub struct Encode(pub std::collections::HashMap); +impl std::ops::Deref for Encode { + type Target = std::collections::HashMap; + fn deref(&self) -> &std::collections::HashMap { + &self.0 + } +} +impl From for std::collections::HashMap { + fn from(value: Encode) -> Self { + value.0 + } +} impl From<&Encode> for Encode { fn from(value: &Encode) -> Self { value.clone() } } +impl From> for Encode { + fn from(value: std::collections::HashMap) -> Self { + Self(value) + } +} #[doc = "EncodeEntry"] #[doc = r""] #[doc = r"
JSON schema"] @@ -30419,6 +30434,74 @@ impl From<&EncodeEntry> for EncodeEntry { value.clone() } } +#[doc = "EncodeKey"] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"type\": \"string\","] +#[doc = " \"pattern\": \"^.+$\""] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde :: Serialize)] +pub struct EncodeKey(String); +impl std::ops::Deref for EncodeKey { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(value: EncodeKey) -> Self { + value.0 + } +} +impl From<&EncodeKey> for EncodeKey { + fn from(value: &EncodeKey) -> Self { + value.clone() + } +} +impl std::str::FromStr for EncodeKey { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if regress::Regex::new("^.+$").unwrap().find(value).is_none() { + return Err("doesn't match pattern \"^.+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl std::convert::TryFrom<&str> for EncodeKey { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for EncodeKey { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for EncodeKey { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +impl<'de> serde::Deserialize<'de> for EncodeKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} #[doc = "Everything"] #[doc = r""] #[doc = r"
JSON schema"] @@ -105101,7 +105184,7 @@ impl From for TitleVariant1Dy { #[derive(Clone, Debug, serde :: Deserialize, serde :: Serialize)] pub struct TitleVariant1Encode { #[serde(flatten, default, skip_serializing_if = "Option::is_none")] - pub subtype_0: Option, + pub subtype_0: Option>, #[serde(flatten, default, skip_serializing_if = "Option::is_none")] pub subtype_1: Option, } @@ -105110,30 +105193,78 @@ impl From<&TitleVariant1Encode> for TitleVariant1Encode { value.clone() } } -#[doc = "TitleVariant1EncodeSubtype0"] +#[doc = "TitleVariant1EncodeSubtype0Key"] #[doc = r""] #[doc = r"
JSON schema"] #[doc = r""] #[doc = r" ```json"] #[doc = "{"] -#[doc = " \"type\": \"object\","] -#[doc = " \"patternProperties\": {"] -#[doc = " \"^(?!interactive|name|style).+$\": {"] -#[doc = " \"$ref\": \"#/definitions/encodeEntry\""] -#[doc = " }"] -#[doc = " },"] -#[doc = " \"additionalProperties\": false"] +#[doc = " \"type\": \"string\","] +#[doc = " \"pattern\": \"^(?!interactive|name|style).+$\""] #[doc = "}"] #[doc = r" ```"] #[doc = r"
"] -#[derive(Clone, Debug, serde :: Deserialize, serde :: Serialize)] -#[serde(deny_unknown_fields)] -pub struct TitleVariant1EncodeSubtype0 {} -impl From<&TitleVariant1EncodeSubtype0> for TitleVariant1EncodeSubtype0 { - fn from(value: &TitleVariant1EncodeSubtype0) -> Self { +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde :: Serialize)] +pub struct TitleVariant1EncodeSubtype0Key(String); +impl std::ops::Deref for TitleVariant1EncodeSubtype0Key { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(value: TitleVariant1EncodeSubtype0Key) -> Self { + value.0 + } +} +impl From<&TitleVariant1EncodeSubtype0Key> for TitleVariant1EncodeSubtype0Key { + fn from(value: &TitleVariant1EncodeSubtype0Key) -> Self { value.clone() } } +impl std::str::FromStr for TitleVariant1EncodeSubtype0Key { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if regress::Regex::new("^(?!interactive|name|style).+$") + .unwrap() + .find(value) + .is_none() + { + return Err("doesn't match pattern \"^(?!interactive|name|style).+$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl std::convert::TryFrom<&str> for TitleVariant1EncodeSubtype0Key { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for TitleVariant1EncodeSubtype0Key { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for TitleVariant1EncodeSubtype0Key { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +impl<'de> serde::Deserialize<'de> for TitleVariant1EncodeSubtype0Key { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} #[doc = "TitleVariant1EncodeSubtype1"] #[doc = r""] #[doc = r"
JSON schema"] diff --git a/typify/tests/schemas/property-pattern.json b/typify/tests/schemas/property-pattern.json new file mode 100644 index 00000000..4d547873 --- /dev/null +++ b/typify/tests/schemas/property-pattern.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "test grammar for pattern properties", + "type": "object", + "required": [ + "rules" + ], + "additionalProperties": false, + "properties": { + "rules": { + "type": "object", + "patternProperties": { + "^[a-zA-Z_]\\w*$": { + "type": "string" + } + }, + "additionalProperties": false + } + } +} \ No newline at end of file diff --git a/typify/tests/schemas/property-pattern.rs b/typify/tests/schemas/property-pattern.rs new file mode 100644 index 00000000..f03b9828 --- /dev/null +++ b/typify/tests/schemas/property-pattern.rs @@ -0,0 +1,135 @@ +#[doc = r" Error types."] +pub mod error { + #[doc = r" Error from a TryFrom or FromStr implementation."] + pub struct ConversionError(std::borrow::Cow<'static, str>); + impl std::error::Error for ConversionError {} + impl std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + std::fmt::Display::fmt(&self.0, f) + } + } + impl std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +#[doc = "TestGrammarForPatternProperties"] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"title\": \"test grammar for pattern properties\","] +#[doc = " \"type\": \"object\","] +#[doc = " \"required\": ["] +#[doc = " \"rules\""] +#[doc = " ],"] +#[doc = " \"properties\": {"] +#[doc = " \"rules\": {"] +#[doc = " \"type\": \"object\","] +#[doc = " \"patternProperties\": {"] +#[doc = " \"^[a-zA-Z_]\\\\w*$\": {"] +#[doc = " \"type\": \"string\""] +#[doc = " }"] +#[doc = " },"] +#[doc = " \"additionalProperties\": false"] +#[doc = " }"] +#[doc = " },"] +#[doc = " \"additionalProperties\": false"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(Clone, Debug, serde :: Deserialize, serde :: Serialize)] +#[serde(deny_unknown_fields)] +pub struct TestGrammarForPatternProperties { + pub rules: std::collections::HashMap, +} +impl From<&TestGrammarForPatternProperties> for TestGrammarForPatternProperties { + fn from(value: &TestGrammarForPatternProperties) -> Self { + value.clone() + } +} +#[doc = "TestGrammarForPatternPropertiesRulesKey"] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"type\": \"string\","] +#[doc = " \"pattern\": \"^[a-zA-Z_]\\\\w*$\""] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde :: Serialize)] +pub struct TestGrammarForPatternPropertiesRulesKey(String); +impl std::ops::Deref for TestGrammarForPatternPropertiesRulesKey { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(value: TestGrammarForPatternPropertiesRulesKey) -> Self { + value.0 + } +} +impl From<&TestGrammarForPatternPropertiesRulesKey> for TestGrammarForPatternPropertiesRulesKey { + fn from(value: &TestGrammarForPatternPropertiesRulesKey) -> Self { + value.clone() + } +} +impl std::str::FromStr for TestGrammarForPatternPropertiesRulesKey { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if regress::Regex::new("^[a-zA-Z_]\\w*$") + .unwrap() + .find(value) + .is_none() + { + return Err("doesn't match pattern \"^[a-zA-Z_]\\w*$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl std::convert::TryFrom<&str> for TestGrammarForPatternPropertiesRulesKey { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for TestGrammarForPatternPropertiesRulesKey { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for TestGrammarForPatternPropertiesRulesKey { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +impl<'de> serde::Deserialize<'de> for TestGrammarForPatternPropertiesRulesKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +fn main() {}