Repository: GREsau/schemars
Pull request #417 (2025-06-01 11:46:23)

Use `patternProperties` on some map schemas
Addresses #235 and #124

Generate different schemas for maps depending on their key type:
- if the schema for the key type has `pattern`, then use that for the map schema's `patternProperties`
- if the key type is an integer, then set an appropriate `patternProperties` matching integers
- otherwise, maintain the previous behaviour of using `additionalProperties`

This requires the key type of maps to implement `JsonSchema`, which is a breaking change, but should _usually_ be fine (especially for the common case of `SomeMap<String, V>`)


Patch:
diff --git a/schemars/src/json_schema_impls/decimal.rs b/schemars/src/json_schema_impls/decimal.rs
index 250e3fb..08c3123 100644
--- a/schemars/src/json_schema_impls/decimal.rs
+++ b/schemars/src/json_schema_impls/decimal.rs
@@ -17,9 +17,9 @@ macro_rules! decimal_impl {
                 let (ty, pattern) = match generator.contract() {
                     Contract::Deserialize => (
                         Value::Array(vec!["string".into(), "number".into()]),
-                        r"^-?[0-9]+(\.[0-9]+)?([eE][0-9]+)?$".into(),
+                        r"^-?\d+(\.\d+)?([eE]\d+)?$".into(),
                     ),
-                    Contract::Serialize => ("string".into(), r"^-?[0-9]+(\.[0-9]+)?$".into()),
+                    Contract::Serialize => ("string".into(), r"^-?\d+(\.\d+)?$".into()),
                 };
 
                 let mut result = Schema::default();
diff --git a/schemars/src/json_schema_impls/indexmap2.rs b/schemars/src/json_schema_impls/indexmap2.rs
index 355864e..a0ff606 100644
--- a/schemars/src/json_schema_impls/indexmap2.rs
+++ b/schemars/src/json_schema_impls/indexmap2.rs
@@ -2,5 +2,5 @@ use crate::JsonSchema;
 use alloc::collections::{BTreeMap, BTreeSet};
 use indexmap2::{IndexMap, IndexSet};
 
-forward_impl!((<K, V: JsonSchema, H> JsonSchema for IndexMap<K, V, H>) => BTreeMap<K, V>);
+forward_impl!((<K: JsonSchema, V: JsonSchema, H> JsonSchema for IndexMap<K, V, H>) => BTreeMap<K, V>);
 forward_impl!((<T: JsonSchema, H> JsonSchema for IndexSet<T, H>) => BTreeSet<T>);
diff --git a/schemars/src/json_schema_impls/maps.rs b/schemars/src/json_schema_impls/maps.rs
index 9926034..d7da171 100644
--- a/schemars/src/json_schema_impls/maps.rs
+++ b/schemars/src/json_schema_impls/maps.rs
@@ -1,34 +1,63 @@
 use crate::_alloc_prelude::*;
 use crate::{json_schema, JsonSchema, Schema, SchemaGenerator};
 use alloc::borrow::Cow;
+use alloc::collections::BTreeMap;
+use serde_json::{json, Value};
 
-macro_rules! map_impl {
-    ($($desc:tt)+) => {
-        impl $($desc)+
-        where
-            V: JsonSchema,
-        {
-            inline_schema!();
+impl<K, V> JsonSchema for BTreeMap<K, V>
+where
+    K: JsonSchema,
+    V: JsonSchema,
+{
+    inline_schema!();
 
-            fn schema_name() -> Cow<'static, str> {
-                format!("Map_of_{}", V::schema_name()).into()
-            }
+    fn schema_name() -> Cow<'static, str> {
+        Cow::Owned(if K::schema_id() == <str>::schema_id() {
+            format!("Map_of_{}", V::schema_name())
+        } else {
+            format!("Map_from_{}_to_{}", K::schema_name(), V::schema_name())
+        })
+    }
 
-            fn schema_id() -> Cow<'static, str> {
-                format!("Map<{}>", V::schema_id()).into()
-            }
+    fn schema_id() -> Cow<'static, str> {
+        Cow::Owned(if K::schema_id() == <str>::schema_id() {
+            format!("Map<{}>", V::schema_id())
+        } else {
+            format!("Map<{}, {}>", K::schema_id(), V::schema_id())
+        })
+    }
+
+    fn json_schema(generator: &mut SchemaGenerator) -> Schema {
+        let key_schema = K::json_schema(generator);
+        let value_schema = generator.subschema_for::<V>();
+
+        let mut map_schema = json_schema!({
+            "type": "object",
+        });
 
-            fn json_schema(generator: &mut SchemaGenerator) -> Schema {
-                json_schema!({
-                    "type": "object",
-                    "additionalProperties": generator.subschema_for::<V>(),
-                })
+        let key_pattern = key_schema.get("pattern").and_then(Value::as_str);
+        let key_type = key_schema.get("type").and_then(Value::as_str);
+        let key_minimum = key_schema.get("minimum").and_then(Value::as_u64);
+
+        let pattern = match (key_pattern, key_type) {
+            (Some(pattern), Some("string")) => pattern,
+            (_, Some("integer")) if key_minimum == Some(0) => r"^\d+$",
+            (_, Some("integer")) => r"^-?\d+$",
+            _ => {
+                map_schema.insert("additionalProperties".to_owned(), value_schema.to_value());
+                return map_schema;
             }
-        }
-    };
-}
+        };
 
-map_impl!(<K, V> JsonSchema for alloc::collections::BTreeMap<K, V>);
+        map_schema.insert(
+            "patternProperties".to_owned(),
+            json!({ pattern: value_schema }),
+        );
+        map_schema.insert("additionalProperties".to_owned(), Value::Bool(false));
+
+        map_schema
+    }
+}
 
 #[cfg(feature = "std")]
-map_impl!(<K, V, H> JsonSchema for std::collections::HashMap<K, V, H>);
+forward_impl!((<K: JsonSchema, V: JsonSchema, H> JsonSchema for std::collections::HashMap<K, V, H>) => BTreeMap<K, V>);
diff --git a/schemars/src/json_schema_impls/mod.rs b/schemars/src/json_schema_impls/mod.rs
index a5e076c..9ec7937 100644
--- a/schemars/src/json_schema_impls/mod.rs
+++ b/schemars/src/json_schema_impls/mod.rs
@@ -10,27 +10,29 @@ macro_rules! forward_impl {
     (($($impl:tt)+) => $target:ty) => {
         impl $($impl)+ {
             fn inline_schema() -> bool {
-                <$target>::inline_schema()
+                <$target as $crate::JsonSchema>::inline_schema()
             }
 
             fn schema_name() -> alloc::borrow::Cow<'static, str> {
-                <$target>::schema_name()
+                <$target as $crate::JsonSchema>::schema_name()
             }
 
             fn schema_id() -> alloc::borrow::Cow<'static, str> {
-                <$target>::schema_id()
+                <$target as $crate::JsonSchema>::schema_id()
             }
 
             fn json_schema(generator: &mut $crate::SchemaGenerator) -> $crate::Schema {
-                <$target>::json_schema(generator)
+                <$target as $crate::JsonSchema>::json_schema(generator)
             }
 
             fn _schemars_private_non_optional_json_schema(generator: &mut $crate::SchemaGenerator) -> $crate::Schema {
-                <$target>::_schemars_private_non_optional_json_schema(generator)
+                #[allow(clippy::used_underscore_items)]
+                <$target as $crate::JsonSchema>::_schemars_private_non_optional_json_schema(generator)
             }
 
             fn _schemars_private_is_option() -> bool {
-                <$target>::_schemars_private_is_option()
+                #[allow(clippy::used_underscore_items)]
+                <$target as $crate::JsonSchema>::_schemars_private_is_option()
             }
         }
     };


Test patch:
diff --git a/schemars/tests/integration/main.rs b/schemars/tests/integration/main.rs
index 27b36ca..32bd216 100644
--- a/schemars/tests/integration/main.rs
+++ b/schemars/tests/integration/main.rs
@@ -32,6 +32,7 @@ mod inline_subschemas;
 #[cfg(feature = "jiff02")]
 mod jiff;
 mod macros;
+mod map;
 mod remote_derive;
 mod same_name;
 mod schema_name;
diff --git a/schemars/tests/integration/map.rs b/schemars/tests/integration/map.rs
new file mode 100644
index 0000000..d545345
--- /dev/null
+++ b/schemars/tests/integration/map.rs
@@ -0,0 +1,27 @@
+use crate::prelude::*;
+use std::collections::{BTreeMap, HashMap};
+
+#[derive(JsonSchema, Deserialize, Serialize, Default, PartialEq, Eq, PartialOrd, Ord)]
+#[schemars(extend("pattern" = "^[0-9a-f]*$"))]
+struct HexNumber(String);
+
+#[derive(JsonSchema, Deserialize, Serialize, Default)]
+struct Maps {
+    s_map: HashMap<String, bool>,
+    i_map: BTreeMap<i8, bool>,
+    u_map: HashMap<u64, bool>,
+    pattern_map: BTreeMap<HexNumber, bool>,
+}
+
+#[test]
+fn maps() {
+    test!(Maps)
+        .assert_snapshot()
+        .assert_allows_ser_roundtrip([Maps {
+            s_map: HashMap::from_iter([("test".to_owned(), true)]),
+            i_map: BTreeMap::from_iter([(-123, true), (123, true)]),
+            u_map: HashMap::from_iter([(123, true)]),
+            pattern_map: BTreeMap::from_iter([(HexNumber("b4df00d".to_owned()), true)]),
+        }])
+        .assert_matches_de_roundtrip(arbitrary_values());
+}
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.de.json b/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.de.json
index 5e1f4c8..4864440 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.de.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.de.json
@@ -5,5 +5,5 @@
     "string",
     "number"
   ],
-  "pattern": "^-?[0-9]+(\\.[0-9]+)?([eE][0-9]+)?$"
+  "pattern": "^-?\\d+(\\.\\d+)?([eE]\\d+)?$"
 }
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.ser.json b/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.ser.json
index e94ca27..f9daadc 100644
--- a/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.ser.json
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/decimal.rs~decimal_types.ser.json
@@ -2,5 +2,5 @@
   "$schema": "https://json-schema.org/draft/2020-12/schema",
   "title": "Decimal",
   "type": "string",
-  "pattern": "^-?[0-9]+(\\.[0-9]+)?$"
+  "pattern": "^-?\\d+(\\.\\d+)?$"
 }
\ No newline at end of file
diff --git a/schemars/tests/integration/snapshots/schemars/tests/integration/map.rs~maps.json b/schemars/tests/integration/snapshots/schemars/tests/integration/map.rs~maps.json
new file mode 100644
index 0000000..8aff5d0
--- /dev/null
+++ b/schemars/tests/integration/snapshots/schemars/tests/integration/map.rs~maps.json
@@ -0,0 +1,46 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "title": "Maps",
+  "type": "object",
+  "properties": {
+    "s_map": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "boolean"
+      }
+    },
+    "i_map": {
+      "type": "object",
+      "patternProperties": {
+        "^-?\\d+$": {
+          "type": "boolean"
+        }
+      },
+      "additionalProperties": false
+    },
+    "u_map": {
+      "type": "object",
+      "patternProperties": {
+        "^\\d+$": {
+          "type": "boolean"
+        }
+      },
+      "additionalProperties": false
+    },
+    "pattern_map": {
+      "type": "object",
+      "patternProperties": {
+        "^[0-9a-f]*$": {
+          "type": "boolean"
+        }
+      },
+      "additionalProperties": false
+    }
+  },
+  "required": [
+    "s_map",
+    "i_map",
+    "u_map",
+    "pattern_map"
+  ]
+}
\ No newline at end of file