Skip to content

Commit 7060aa1

Browse files
authored
improve merging of strings and numbers (#998)
1 parent 1c0afa7 commit 7060aa1

File tree

3 files changed

+242
-5
lines changed

3 files changed

+242
-5
lines changed

typify-impl/src/merge.rs

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -720,8 +720,52 @@ fn merge_so_number(
720720
match (a, b) {
721721
(None, other) | (other, None) => Ok(other.cloned().map(Box::new)),
722722
(Some(a), Some(b)) if a == b => Ok(Some(Box::new(a.clone()))),
723-
(Some(_), Some(_)) => {
724-
unimplemented!("this is fairly fussy and I don't want to do it")
723+
(Some(a), Some(b)) => {
724+
let maximum = choose_value(a.maximum, b.maximum, f64::min);
725+
let exclusive_maximum =
726+
choose_value(a.exclusive_maximum, b.exclusive_maximum, f64::min);
727+
let minimum = choose_value(a.minimum, b.minimum, f64::max);
728+
let exclusive_minimum =
729+
choose_value(a.exclusive_minimum, b.exclusive_minimum, f64::max);
730+
731+
let multiple_of = choose_value(a.multiple_of, b.multiple_of, |a, b| {
732+
let (mut x, mut y) = (a, b);
733+
while y != 0.0 {
734+
(x, y) = (y, x % y);
735+
}
736+
(a / x) * b
737+
});
738+
739+
// Normalize: when both inclusive and exclusive bounds are set,
740+
// drop whichever is subsumed by the other.
741+
let (minimum, exclusive_minimum) = match (minimum, exclusive_minimum) {
742+
(Some(inc), Some(exc)) if exc >= inc => (None, Some(exc)),
743+
(Some(inc), Some(_exc)) => (Some(inc), None),
744+
pair => pair,
745+
};
746+
let (maximum, exclusive_maximum) = match (maximum, exclusive_maximum) {
747+
(Some(inc), Some(exc)) if exc <= inc => (None, Some(exc)),
748+
(Some(inc), Some(_exc)) => (Some(inc), None),
749+
pair => pair,
750+
};
751+
752+
// We'll return an error if the merged schema is unsatisfiable.
753+
match (minimum, exclusive_minimum, maximum, exclusive_maximum) {
754+
(Some(min), None, Some(max), None) if min > max => return Err(()),
755+
(Some(min), None, None, Some(xmax)) if min >= xmax => return Err(()),
756+
(None, Some(xmin), Some(max), None) if xmin >= max => return Err(()),
757+
(None, Some(xmin), None, Some(xmax)) if xmin >= xmax => return Err(()),
758+
(Some(_), Some(_), _, _) | (_, _, Some(_), Some(_)) => unreachable!(),
759+
_ => {}
760+
}
761+
762+
Ok(Some(Box::new(NumberValidation {
763+
multiple_of,
764+
maximum,
765+
exclusive_maximum,
766+
minimum,
767+
exclusive_minimum,
768+
})))
725769
}
726770
}
727771
}
@@ -733,8 +777,26 @@ fn merge_so_string(
733777
match (a, b) {
734778
(None, other) | (other, None) => Ok(other.cloned().map(Box::new)),
735779
(Some(a), Some(b)) if a == b => Ok(Some(Box::new(a.clone()))),
736-
(Some(_), Some(_)) => {
737-
unimplemented!("this is fairly fussy and I don't want to do it")
780+
(Some(a), Some(b)) => {
781+
let max_length = choose_value(a.max_length, b.max_length, Ord::min);
782+
let min_length = choose_value(a.min_length, b.min_length, Ord::max);
783+
let pattern = match (&a.pattern, &b.pattern) {
784+
(None, v) | (v, None) => v.clone(),
785+
(Some(x), Some(y)) if x == y => Some(x.clone()),
786+
_ => unimplemented!("merging distinct patterns is impractical"),
787+
};
788+
789+
if let (Some(min), Some(max)) = (min_length, max_length) {
790+
if min > max {
791+
return Err(());
792+
}
793+
}
794+
795+
Ok(Some(Box::new(StringValidation {
796+
max_length,
797+
min_length,
798+
pattern,
799+
})))
738800
}
739801
}
740802
}
@@ -961,7 +1023,6 @@ fn merge_items_array<'a>(
9611023
Ok((items, true))
9621024
}
9631025

964-
/// Prefer Some over None and the result of `prefer` if both are Some.
9651026
fn choose_value<T, F>(a: Option<T>, b: Option<T>, prefer: F) -> Option<T>
9661027
where
9671028
F: FnOnce(T, T) -> T,

typify/tests/schemas/merged-schemas.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,36 @@
491491
}
492492
]
493493
},
494+
"merge-number-bounds": {
495+
"$comment": "merging number constraints takes the most restrictive bounds; mixed inclusive/exclusive bounds are normalized to drop the subsumed one",
496+
"allOf": [
497+
{
498+
"type": "number",
499+
"minimum": 1.0,
500+
"maximum": 100.0
501+
},
502+
{
503+
"type": "number",
504+
"exclusiveMinimum": 5.0,
505+
"exclusiveMaximum": 50.0
506+
}
507+
]
508+
},
509+
"merge-string-bounds": {
510+
"$comment": "merging string constraints takes the most restrictive bounds: max_length takes the min, min_length takes the max",
511+
"allOf": [
512+
{
513+
"type": "string",
514+
"minLength": 5,
515+
"maxLength": 20
516+
},
517+
{
518+
"type": "string",
519+
"minLength": 2,
520+
"maxLength": 10
521+
}
522+
]
523+
},
494524
"unchanged-by-merge": {
495525
"allOf": [
496526
{

typify/tests/schemas/merged-schemas.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,152 @@ impl MergeEmpty {
481481
Default::default()
482482
}
483483
}
484+
#[doc = "`MergeNumberBounds`"]
485+
#[doc = r""]
486+
#[doc = r" <details><summary>JSON schema</summary>"]
487+
#[doc = r""]
488+
#[doc = r" ```json"]
489+
#[doc = "{"]
490+
#[doc = " \"allOf\": ["]
491+
#[doc = " {"]
492+
#[doc = " \"type\": \"number\","]
493+
#[doc = " \"maximum\": 100.0,"]
494+
#[doc = " \"minimum\": 1.0"]
495+
#[doc = " },"]
496+
#[doc = " {"]
497+
#[doc = " \"type\": \"number\","]
498+
#[doc = " \"exclusiveMaximum\": 50.0,"]
499+
#[doc = " \"exclusiveMinimum\": 5.0"]
500+
#[doc = " }"]
501+
#[doc = " ],"]
502+
#[doc = " \"$comment\": \"merging number constraints takes the most restrictive bounds; mixed inclusive/exclusive bounds are normalized to drop the subsumed one\""]
503+
#[doc = "}"]
504+
#[doc = r" ```"]
505+
#[doc = r" </details>"]
506+
#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)]
507+
#[serde(transparent)]
508+
pub struct MergeNumberBounds(pub f64);
509+
impl ::std::ops::Deref for MergeNumberBounds {
510+
type Target = f64;
511+
fn deref(&self) -> &f64 {
512+
&self.0
513+
}
514+
}
515+
impl ::std::convert::From<MergeNumberBounds> for f64 {
516+
fn from(value: MergeNumberBounds) -> Self {
517+
value.0
518+
}
519+
}
520+
impl ::std::convert::From<f64> for MergeNumberBounds {
521+
fn from(value: f64) -> Self {
522+
Self(value)
523+
}
524+
}
525+
impl ::std::str::FromStr for MergeNumberBounds {
526+
type Err = <f64 as ::std::str::FromStr>::Err;
527+
fn from_str(value: &str) -> ::std::result::Result<Self, Self::Err> {
528+
Ok(Self(value.parse()?))
529+
}
530+
}
531+
impl ::std::convert::TryFrom<&str> for MergeNumberBounds {
532+
type Error = <f64 as ::std::str::FromStr>::Err;
533+
fn try_from(value: &str) -> ::std::result::Result<Self, Self::Error> {
534+
value.parse()
535+
}
536+
}
537+
impl ::std::convert::TryFrom<String> for MergeNumberBounds {
538+
type Error = <f64 as ::std::str::FromStr>::Err;
539+
fn try_from(value: String) -> ::std::result::Result<Self, Self::Error> {
540+
value.parse()
541+
}
542+
}
543+
impl ::std::fmt::Display for MergeNumberBounds {
544+
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
545+
self.0.fmt(f)
546+
}
547+
}
548+
#[doc = "`MergeStringBounds`"]
549+
#[doc = r""]
550+
#[doc = r" <details><summary>JSON schema</summary>"]
551+
#[doc = r""]
552+
#[doc = r" ```json"]
553+
#[doc = "{"]
554+
#[doc = " \"allOf\": ["]
555+
#[doc = " {"]
556+
#[doc = " \"type\": \"string\","]
557+
#[doc = " \"maxLength\": 20,"]
558+
#[doc = " \"minLength\": 5"]
559+
#[doc = " },"]
560+
#[doc = " {"]
561+
#[doc = " \"type\": \"string\","]
562+
#[doc = " \"maxLength\": 10,"]
563+
#[doc = " \"minLength\": 2"]
564+
#[doc = " }"]
565+
#[doc = " ],"]
566+
#[doc = " \"$comment\": \"merging string constraints takes the most restrictive bounds: max_length takes the min, min_length takes the max\""]
567+
#[doc = "}"]
568+
#[doc = r" ```"]
569+
#[doc = r" </details>"]
570+
#[derive(:: serde :: Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
571+
#[serde(transparent)]
572+
pub struct MergeStringBounds(::std::string::String);
573+
impl ::std::ops::Deref for MergeStringBounds {
574+
type Target = ::std::string::String;
575+
fn deref(&self) -> &::std::string::String {
576+
&self.0
577+
}
578+
}
579+
impl ::std::convert::From<MergeStringBounds> for ::std::string::String {
580+
fn from(value: MergeStringBounds) -> Self {
581+
value.0
582+
}
583+
}
584+
impl ::std::str::FromStr for MergeStringBounds {
585+
type Err = self::error::ConversionError;
586+
fn from_str(value: &str) -> ::std::result::Result<Self, self::error::ConversionError> {
587+
if value.chars().count() > 10usize {
588+
return Err("longer than 10 characters".into());
589+
}
590+
if value.chars().count() < 5usize {
591+
return Err("shorter than 5 characters".into());
592+
}
593+
Ok(Self(value.to_string()))
594+
}
595+
}
596+
impl ::std::convert::TryFrom<&str> for MergeStringBounds {
597+
type Error = self::error::ConversionError;
598+
fn try_from(value: &str) -> ::std::result::Result<Self, self::error::ConversionError> {
599+
value.parse()
600+
}
601+
}
602+
impl ::std::convert::TryFrom<&::std::string::String> for MergeStringBounds {
603+
type Error = self::error::ConversionError;
604+
fn try_from(
605+
value: &::std::string::String,
606+
) -> ::std::result::Result<Self, self::error::ConversionError> {
607+
value.parse()
608+
}
609+
}
610+
impl ::std::convert::TryFrom<::std::string::String> for MergeStringBounds {
611+
type Error = self::error::ConversionError;
612+
fn try_from(
613+
value: ::std::string::String,
614+
) -> ::std::result::Result<Self, self::error::ConversionError> {
615+
value.parse()
616+
}
617+
}
618+
impl<'de> ::serde::Deserialize<'de> for MergeStringBounds {
619+
fn deserialize<D>(deserializer: D) -> ::std::result::Result<Self, D::Error>
620+
where
621+
D: ::serde::Deserializer<'de>,
622+
{
623+
::std::string::String::deserialize(deserializer)?
624+
.parse()
625+
.map_err(|e: self::error::ConversionError| {
626+
<D::Error as ::serde::de::Error>::custom(e.to_string())
627+
})
628+
}
629+
}
484630
#[doc = "`NarrowNumber`"]
485631
#[doc = r""]
486632
#[doc = r" <details><summary>JSON schema</summary>"]

0 commit comments

Comments
 (0)