Repository: GREsau/schemars
Pull request #440 (2025-06-23 07:51:32)

Respect `#[schemars(bound = ...)]` attributes on fields as well as containers
Addresses #169


Patch:
diff --git a/schemars/src/generate.rs b/schemars/src/generate.rs
index 6b0117e..82977d8 100644
--- a/schemars/src/generate.rs
+++ b/schemars/src/generate.rs
@@ -294,51 +294,80 @@ impl SchemaGenerator {
     /// If `T`'s schema depends on any [non-inlined](JsonSchema::inline_schema) schemas, then
     /// this method will add them to the `SchemaGenerator`'s schema definitions.
     pub fn subschema_for<T: ?Sized + JsonSchema>(&mut self) -> Schema {
-        let uid = self.schema_uid::<T>();
-        let return_ref = !T::inline_schema()
-            && (!self.settings.inline_subschemas || self.pending_schema_ids.contains(&uid));
+        struct FindRef {
+            schema: Schema,
+            name_to_be_inserted: Option<CowStr>,
+        }
 
-        if return_ref {
-            if self.root_schema_id_stack.last() == Some(&uid) {
-                return Schema::new_ref("#".to_owned());
+        /// Non-generic inner function to improve compile times.
+        fn find_ref(
+            this: &mut SchemaGenerator,
+            uid: &SchemaUid,
+            inline_schema: bool,
+            schema_name: fn() -> CowStr,
+        ) -> Option<FindRef> {
+            let return_ref = !inline_schema
+                && (!this.settings.inline_subschemas || this.pending_schema_ids.contains(uid));
+
+            if !return_ref {
+                return None;
+            }
+
+            if this.root_schema_id_stack.last() == Some(uid) {
+                return Some(FindRef {
+                    schema: Schema::new_ref("#".to_owned()),
+                    name_to_be_inserted: None,
+                });
             }
 
-            let name = self
-                .schema_id_to_name
-                .get(&uid)
-                .cloned()
-                .unwrap_or_else(|| {
-                    let base_name = T::schema_name();
-                    let mut name = CowStr::Borrowed("");
-
-                    if self.used_schema_names.contains(base_name.as_ref()) {
-                        for i in 2.. {
-                            name = format!("{base_name}{i}").into();
-                            if !self.used_schema_names.contains(&name) {
-                                break;
-                            }
+            let name = this.schema_id_to_name.get(uid).cloned().unwrap_or_else(|| {
+                let base_name = schema_name();
+                let mut name = CowStr::Borrowed("");
+
+                if this.used_schema_names.contains(base_name.as_ref()) {
+                    for i in 2.. {
+                        name = format!("{base_name}{i}").into();
+                        if !this.used_schema_names.contains(&name) {
+                            break;
                         }
-                    } else {
-                        name = base_name;
                     }
+                } else {
+                    name = base_name;
+                }
 
-                    self.used_schema_names.insert(name.clone());
-                    self.schema_id_to_name.insert(uid.clone(), name.clone());
-                    name
-                });
+                this.used_schema_names.insert(name.clone());
+                this.schema_id_to_name.insert(uid.clone(), name.clone());
+                name
+            });
 
             let reference = format!(
                 "#{}/{}",
-                self.definitions_path_stripped(),
+                this.definitions_path_stripped(),
                 crate::encoding::encode_ref_name(&name)
             );
-            if !self.definitions.contains_key(name.as_ref()) {
-                self.insert_new_subschema_for::<T>(name, &uid);
-            }
-            Schema::new_ref(reference)
-        } else {
-            self.json_schema_internal::<T>(&uid)
+
+            Some(FindRef {
+                schema: Schema::new_ref(reference),
+                name_to_be_inserted: (!this.definitions().contains_key(name.as_ref()))
+                    .then_some(name),
+            })
         }
+
+        let uid = self.schema_uid::<T>();
+
+        let Some(FindRef {
+            schema,
+            name_to_be_inserted,
+        }) = find_ref(self, &uid, T::inline_schema(), T::schema_name)
+        else {
+            return self.json_schema_internal::<T>(&uid);
+        };
+
+        if let Some(name) = name_to_be_inserted {
+            self.insert_new_subschema_for::<T>(name, &uid);
+        }
+
+        schema
     }
 
     fn insert_new_subschema_for<T: ?Sized + JsonSchema>(&mut self, name: CowStr, uid: &SchemaUid) {
diff --git a/schemars_derive/src/bound.rs b/schemars_derive/src/bound.rs
index 3fbc85b..e1c5227 100644
--- a/schemars_derive/src/bound.rs
+++ b/schemars_derive/src/bound.rs
@@ -23,8 +23,11 @@ pub fn find_trait_bounds<'a>(orig_generics: &'a syn::Generics, cont: &mut Contai
     let mut visitor = FindTyParams {
         all_type_params,
         relevant_type_params: cont.rename_type_params.clone(),
+        type_params_for_bound: cont.rename_type_params.clone(),
     };
 
+    let mut field_explicit_bounds = Vec::new();
+
     if visitor.all_type_params.len() > visitor.relevant_type_params.len() {
         match &cont.data {
             Data::Enum(variants) => {
@@ -35,6 +38,7 @@ pub fn find_trait_bounds<'a>(orig_generics: &'a syn::Generics, cont: &mut Contai
                         .filter(|field| needs_jsonschema_bound(field, Some(variant)));
 
                     for field in relevant_fields {
+                        field_explicit_bounds.extend(field.serde_attrs.de_bound());
                         visitor.visit_field(field);
                     }
                 }
@@ -45,6 +49,7 @@ pub fn find_trait_bounds<'a>(orig_generics: &'a syn::Generics, cont: &mut Contai
                     .filter(|field| needs_jsonschema_bound(field, None));
 
                 for field in relevant_fields {
+                    field_explicit_bounds.extend(field.serde_attrs.de_bound());
                     visitor.visit_field(field);
                 }
             }
@@ -60,7 +65,7 @@ pub fn find_trait_bounds<'a>(orig_generics: &'a syn::Generics, cont: &mut Contai
     } else {
         where_clause
             .predicates
-            .extend(cont.relevant_type_params.iter().map(|ty| {
+            .extend(visitor.type_params_for_bound.into_iter().map(|ty| {
                 syn::WherePredicate::Type(syn::PredicateType {
                     lifetimes: None,
                     bounded_ty: syn::Type::Path(syn::TypePath {
@@ -83,6 +88,10 @@ pub fn find_trait_bounds<'a>(orig_generics: &'a syn::Generics, cont: &mut Contai
                 })
             }));
     }
+
+    where_clause
+        .predicates
+        .extend(field_explicit_bounds.into_iter().flatten().cloned());
 }
 
 fn needs_jsonschema_bound(field: &Field, variant: Option<&Variant>) -> bool {
@@ -91,6 +100,7 @@ fn needs_jsonschema_bound(field: &Field, variant: Option<&Variant>) -> bool {
             return false;
         }
     }
+
     if field.serde_attrs.skip_deserializing() && field.serde_attrs.skip_serializing() {
         return false;
     }
@@ -101,21 +111,22 @@ fn needs_jsonschema_bound(field: &Field, variant: Option<&Variant>) -> bool {
 struct FindTyParams<'ast> {
     all_type_params: BTreeSet<&'ast Ident>,
     relevant_type_params: BTreeSet<&'ast Ident>,
+    type_params_for_bound: BTreeSet<&'ast Ident>,
 }
 
 #[allow(clippy::match_same_arms)]
 impl FindTyParams<'_> {
     fn visit_field(&mut self, field: &Field) {
         match &field.attrs.with {
-            Some(WithAttr::Type(ty)) => self.visit_type(ty),
+            Some(WithAttr::Type(ty)) => self.visit_type(field, ty),
             Some(WithAttr::Function(_)) => {
                 // `schema_with` function type params may or may not implement `JsonSchema`
             }
-            None => self.visit_type(&field.original.ty),
+            None => self.visit_type(field, &field.original.ty),
         }
     }
 
-    fn visit_path(&mut self, path: &syn::Path) {
+    fn visit_path(&mut self, field: &Field, path: &syn::Path) {
         if let Some(seg) = path.segments.last() {
             if seg.ident == "PhantomData" {
                 // Hardcoded exception, because PhantomData<T> implements
@@ -129,51 +140,54 @@ impl FindTyParams<'_> {
                 let id = &first_segment.ident;
                 if let Some(id) = self.all_type_params.get(id) {
                     self.relevant_type_params.insert(id);
+                    if field.serde_attrs.de_bound().is_none() {
+                        self.type_params_for_bound.insert(id);
+                    }
                 }
             }
         }
 
         for segment in &path.segments {
-            self.visit_path_segment(segment);
+            self.visit_path_segment(field, segment);
         }
     }
 
-    fn visit_type(&mut self, ty: &syn::Type) {
+    fn visit_type(&mut self, field: &Field, ty: &syn::Type) {
         match ty {
-            syn::Type::Array(ty) => self.visit_type(&ty.elem),
+            syn::Type::Array(ty) => self.visit_type(field, &ty.elem),
             syn::Type::BareFn(ty) => {
                 for arg in &ty.inputs {
-                    self.visit_type(&arg.ty);
+                    self.visit_type(field, &arg.ty);
                 }
-                self.visit_return_type(&ty.output);
+                self.visit_return_type(field, &ty.output);
             }
-            syn::Type::Group(ty) => self.visit_type(&ty.elem),
+            syn::Type::Group(ty) => self.visit_type(field, &ty.elem),
             syn::Type::ImplTrait(ty) => {
                 for bound in &ty.bounds {
-                    self.visit_type_param_bound(bound);
+                    self.visit_type_param_bound(field, bound);
                 }
             }
-            syn::Type::Macro(ty) => self.visit_macro(&ty.mac),
-            syn::Type::Paren(ty) => self.visit_type(&ty.elem),
+            syn::Type::Macro(ty) => self.visit_macro(field, &ty.mac),
+            syn::Type::Paren(ty) => self.visit_type(field, &ty.elem),
             syn::Type::Path(ty) => {
                 if let Some(qself) = &ty.qself {
-                    self.visit_type(&qself.ty);
+                    self.visit_type(field, &qself.ty);
                 }
-                self.visit_path(&ty.path);
+                self.visit_path(field, &ty.path);
             }
-            syn::Type::Ptr(ty) => self.visit_type(&ty.elem),
+            syn::Type::Ptr(ty) => self.visit_type(field, &ty.elem),
             syn::Type::Reference(ty) => {
-                self.visit_type(&ty.elem);
+                self.visit_type(field, &ty.elem);
             }
-            syn::Type::Slice(ty) => self.visit_type(&ty.elem),
+            syn::Type::Slice(ty) => self.visit_type(field, &ty.elem),
             syn::Type::TraitObject(ty) => {
                 for bound in &ty.bounds {
-                    self.visit_type_param_bound(bound);
+                    self.visit_type_param_bound(field, bound);
                 }
             }
             syn::Type::Tuple(ty) => {
                 for elem in &ty.elems {
-                    self.visit_type(elem);
+                    self.visit_type(field, elem);
                 }
             }
 
@@ -183,18 +197,18 @@ impl FindTyParams<'_> {
         }
     }
 
-    fn visit_path_segment(&mut self, segment: &syn::PathSegment) {
-        self.visit_path_arguments(&segment.arguments);
+    fn visit_path_segment(&mut self, field: &Field, segment: &syn::PathSegment) {
+        self.visit_path_arguments(field, &segment.arguments);
     }
 
-    fn visit_path_arguments(&mut self, arguments: &syn::PathArguments) {
+    fn visit_path_arguments(&mut self, field: &Field, arguments: &syn::PathArguments) {
         match arguments {
             syn::PathArguments::None => {}
             syn::PathArguments::AngleBracketed(arguments) => {
                 for arg in &arguments.args {
                     match arg {
-                        syn::GenericArgument::Type(arg) => self.visit_type(arg),
-                        syn::GenericArgument::AssocType(arg) => self.visit_type(&arg.ty),
+                        syn::GenericArgument::Type(arg) => self.visit_type(field, arg),
+                        syn::GenericArgument::AssocType(arg) => self.visit_type(field, &arg.ty),
                         syn::GenericArgument::Lifetime(_)
                         | syn::GenericArgument::Const(_)
                         | syn::GenericArgument::AssocConst(_)
@@ -205,23 +219,23 @@ impl FindTyParams<'_> {
             }
             syn::PathArguments::Parenthesized(arguments) => {
                 for argument in &arguments.inputs {
-                    self.visit_type(argument);
+                    self.visit_type(field, argument);
                 }
-                self.visit_return_type(&arguments.output);
+                self.visit_return_type(field, &arguments.output);
             }
         }
     }
 
-    fn visit_return_type(&mut self, return_type: &syn::ReturnType) {
+    fn visit_return_type(&mut self, field: &Field, return_type: &syn::ReturnType) {
         match return_type {
             syn::ReturnType::Default => {}
-            syn::ReturnType::Type(_, output) => self.visit_type(output),
+            syn::ReturnType::Type(_, output) => self.visit_type(field, output),
         }
     }
 
-    fn visit_type_param_bound(&mut self, bound: &syn::TypeParamBound) {
+    fn visit_type_param_bound(&mut self, field: &Field, bound: &syn::TypeParamBound) {
         match bound {
-            syn::TypeParamBound::Trait(bound) => self.visit_path(&bound.path),
+            syn::TypeParamBound::Trait(bound) => self.visit_path(field, &bound.path),
             syn::TypeParamBound::Lifetime(_)
             | syn::TypeParamBound::PreciseCapture(_)
             | syn::TypeParamBound::Verbatim(_) => {}
@@ -236,7 +250,7 @@ impl FindTyParams<'_> {
     //         marker: PhantomData<T>,
     //     }
     #[allow(clippy::unused_self)]
-    fn visit_macro(&mut self, _mac: &syn::Macro) {}
+    fn visit_macro(&mut self, _field: &Field, _mac: &syn::Macro) {}
 }
 
 #[cfg(test)]


Test patch:
diff --git a/schemars/tests/integration/bound.rs b/schemars/tests/integration/bound.rs
index 7caffc2..5e8018f 100644
--- a/schemars/tests/integration/bound.rs
+++ b/schemars/tests/integration/bound.rs
@@ -12,16 +12,17 @@ impl Iterator for MyIterator {
     }
 }
 
-// The default trait bounds would require T to implement JsonSchema, which MyIterator does not.
-// Ideally we wouldn't need the `bound` attribute here at all - it should be possible to better
-// infer automatic trait bounds (tracked in https://github.com/GREsau/schemars/issues/168)
+// The default trait bounds would require T/U to implement JsonSchema, which MyIterator does not.
 #[derive(JsonSchema, Serialize, Deserialize, Default)]
 #[schemars(bound = "T::Item: JsonSchema")]
-pub struct MyContainer<T>
+pub struct MyContainer<T, U>
 where
     T: Iterator,
+    U: Iterator,
 {
     pub associated: T::Item,
+    #[schemars(bound = "U::Item: JsonSchema")]
+    pub associated2: U::Item,
     pub phantom: PhantomData<T>,
     #[serde(skip)]
     pub _skipped: T,
@@ -29,17 +30,27 @@ where
 
 #[test]
 fn manual_bound_set() {
-    test!(MyContainer<MyIterator>)
+    test!(MyContainer<MyIterator, MyIterator>)
         // TODO with better bounds, this assertion would work:
         // .assert_identical::<MyContainer<core::slice::Iter<&str>>>()
         .assert_snapshot()
         .assert_allows_ser_roundtrip_default()
         .assert_allows_ser_roundtrip([MyContainer {
             associated: "test".to_owned(),
+            associated2: "test".to_owned(),
             phantom: PhantomData,
             _skipped: MyIterator,
         }])
         .assert_matches_de_roundtrip(arbitrary_values());
+
+    assert_ne!(
+        <MyContainer<MyIterator, MyIterator>>::schema_id(),
+        <MyContainer<core::slice::Iter<i32>, MyIterator>>::schema_id()
+    );
+    assert_ne!(
+        <MyContainer<MyIterator, MyIterator>>::schema_id(),
+        <MyContainer<MyIterator, core::slice::Iter<i32>>>::schema_id()
+    );
 }
 
 // `T` doesn't need to impl `JsonSchema`, but `U` does
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/bound.rs~manual_bound_set.json b/schemars/tests/integration/snapshots/schemars/tests/integration/bound.rs~manual_bound_set.json
index 70c5308..abae631 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/bound.rs~manual_bound_set.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/bound.rs~manual_bound_set.json
@@ -6,12 +6,16 @@
     "associated": {
       "type": "string"
     },
+    "associated2": {
+      "type": "string"
+    },
     "phantom": {
       "type": "null"
     }
   },
   "required": [
     "associated",
+    "associated2",
     "phantom"
   ]
 }
\ No newline at end of file