Repository: marshmallow-code/marshmallow

Issue:
How to create a Schema containing a dict of nested Schema?
Hi. I've been digging around and couldn't find the answer to this.

Say I've got a model like this:

``` python
class AlbumSchema(Schema):
    year = fields.Int()

class ArtistSchema(Schema):
    name = fields.Str()
    albums = ...
```

I want `albums` to be a `dict` of `AlbumSchema`, so that `ArtistSchema` serializes as

``` python
{ 'albums': { 'Hunky Dory': {'year': 1971},
              'The Man Who Sold the World': {'year': 1970}},
  'name': 'David Bowie'}
```

Naively, I would expect syntaxes like this to work:

``` python
fields.List(Schema)
fields.Dict(Schema)
```

or maybe

``` python
fields.List(fields.Nested(Schema))
fields.Dict(fields.Nested(Schema))
```

Serializing a `list` of `Schema` can be achieved through `Nested(Schema, many=True)`, which I find less intuitive, and I don't know about a `dict` of `Schema`.

Is there any way to do it? Or a good reason _not_ to do it?

(Question also [asked on SO](http://stackoverflow.com/questions/38048775/marshmallow-dict-of-nested-schema).)


Discussion:

Patch:
diff --git a/marshmallow/fields.py b/marshmallow/fields.py
index d2ea022f..5a0835da 100755
--- a/marshmallow/fields.py
+++ b/marshmallow/fields.py
@@ -1125,12 +1125,20 @@ class TimeDelta(Field):
 
 
 class Dict(Field):
-    """A dict field. Supports dicts and dict-like objects.
+    """A dict field. Supports dicts and dict-like objects. Optionally composed
+    with another `Field` class or instance.
+
+    Example: ::
+
+        numbers = fields.Dict(values=fields.Float(), keys=fields.Str())
+
+    :param Field values: A field class or instance for dict values.
+    :param Field keys: A field class or instance for dict keys.
+    :param kwargs: The same keyword arguments that :class:`Field` receives.
 
     .. note::
-        This field is only appropriate when the structure of
-        nested data is not known. For structured data, use
-        `Nested`.
+        When the structure of nested data is not known, you may omit the
+        `values` and `keys` arguments to prevent content validation.
 
     .. versionadded:: 2.1.0
     """
@@ -1139,11 +1147,85 @@ class Dict(Field):
         'invalid': 'Not a valid mapping type.'
     }
 
-    def _deserialize(self, value, attr, data):
-        if isinstance(value, collections.Mapping):
-            return value
+    def __init__(self, values=None, keys=None, **kwargs):
+        super(Dict, self).__init__(**kwargs)
+        if values is None:
+            self.value_container = None
+        elif isinstance(values, type):
+            if not issubclass(values, FieldABC):
+                raise ValueError('"values" must be a subclass of '
+                                 'marshmallow.base.FieldABC')
+            self.value_container = values()
+        else:
+            if not isinstance(values, FieldABC):
+                raise ValueError('"values" must be of type '
+                                 'marshmallow.base.FieldABC')
+            self.value_container = values
+        if keys is None:
+            self.key_container = None
+        elif isinstance(keys, type):
+            if not issubclass(keys, FieldABC):
+                raise ValueError('"keys" must be a subclass of '
+                                 'marshmallow.base.FieldABC')
+            self.key_container = keys()
         else:
+            if not isinstance(keys, FieldABC):
+                raise ValueError('"keys" must be of type '
+                                 'marshmallow.base.FieldABC')
+            self.key_container = keys
+
+    def _serialize(self, value, attr, obj):
+        if value is None:
+            return None
+        if not self.value_container and not self.key_container:
+            return value
+        if isinstance(value, collections.Mapping):
+            values = value.values()
+            if self.value_container:
+                values = [self.value_container._serialize(item, attr, obj) for item in values]
+            keys = value.keys()
+            if self.key_container:
+                keys = [self.key_container._serialize(key, attr, obj) for key in keys]
+            return dict(zip(keys, values))
+        self.fail('invalid')
+
+    def _deserialize(self, value, attr, data):
+        if not isinstance(value, collections.Mapping):
             self.fail('invalid')
+        if not self.value_container and not self.key_container:
+            return value
+
+        errors = {}
+        values = list(value.values())
+        keys = list(value.keys())
+        if self.key_container:
+            for idx, key in enumerate(keys):
+                try:
+                    keys[idx] = self.key_container.deserialize(key)
+                except ValidationError as e:
+                    errors[key] = [
+                        'Invalid key: {}'.format(message)
+                        for message in e.messages
+                    ]
+        if self.value_container:
+            for idx, item in enumerate(values):
+                try:
+                    values[idx] = self.value_container.deserialize(item)
+                except ValidationError as e:
+                    values[idx] = e.data
+                    key = keys[idx]
+                    if key not in errors:
+                        errors[key] = []
+                    errors[key].extend([
+                        'Invalid value: {}'.format(message)
+                        for message in e.messages
+                    ])
+        result = dict(zip(keys, values))
+
+        if errors:
+            raise ValidationError(errors, data=result)
+
+        return result
 
 
 class ValidatedField(Field):


Test patch:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index adaba296..3e2a7c37 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -542,6 +542,51 @@ class TestFieldDeserialization:
             field.deserialize('baddict')
         assert excinfo.value.args[0] == 'Not a valid mapping type.'
 
+    def test_structured_dict_value_deserialization(self):
+        field = fields.Dict(values=fields.Str)
+        assert field.deserialize({"foo": "bar"}) == {"foo": "bar"}
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize({"foo": 1, "bar": "baz"})
+        assert excinfo.value.args[0] == {'foo': ['Invalid value: Not a valid string.']}
+        assert excinfo.value.data == {'bar': 'baz', 'foo': None}
+
+    def test_structured_dict_key_deserialization(self):
+        field = fields.Dict(keys=fields.Str)
+        assert field.deserialize({"foo": "bar"}) == {"foo": "bar"}
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize({1: "bar", "foo": "baz"})
+        assert excinfo.value.args[0] == {1: ['Invalid key: Not a valid string.']}
+        assert excinfo.value.data == {'foo': 'baz', 1: "bar"}
+
+    def test_structured_dict_key_value_deserialization(self):
+        field = fields.Dict(
+            keys=fields.Str(validate=[validate.Email(), validate.Regexp(r'.*@test\.com$')]),
+            values=fields.Decimal,
+        )
+        assert field.deserialize({"foo@test.com": 1}) == {"foo@test.com": decimal.Decimal(1)}
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize({1: "bar"})
+        assert excinfo.value.args[0] == {1: [
+            'Invalid key: Not a valid string.',
+            'Invalid value: Not a valid number.',
+        ]}
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize({"foo@test.com": "bar"})
+        assert excinfo.value.args[0] == {"foo@test.com": ['Invalid value: Not a valid number.']}
+        assert excinfo.value.data == {'foo@test.com': None}
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize({1: 1})
+        assert excinfo.value.args[0] == {1: ['Invalid key: Not a valid string.']}
+        assert excinfo.value.data == {1: 1}
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize({"foo": "bar"})
+        assert excinfo.value.args[0] == {"foo": [
+            'Invalid key: Not a valid email address.',
+            'Invalid key: String does not match expected pattern.',
+            'Invalid value: Not a valid number.',
+        ]}
+        assert excinfo.value.data == {'foo': None}
+
     def test_url_field_deserialization(self):
         field = fields.Url()
         assert field.deserialize('https://duckduckgo.com') == 'https://duckduckgo.com'
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 3f3c290d..718078c7 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -388,6 +388,27 @@ class TestFieldSerialization:
         assert field.serialize('various_data', user) == \
             OrderedDict([("foo", "bar"), ("bar", "baz")])
 
+    def test_structured_dict_value_serialize(self, user):
+        user.various_data = {"foo": decimal.Decimal('1')}
+        field = fields.Dict(values=fields.Decimal)
+        assert field.serialize('various_data', user) == {"foo": 1}
+
+    def test_structured_dict_key_serialize(self, user):
+        user.various_data = {1: "bar"}
+        field = fields.Dict(keys=fields.Str)
+        assert field.serialize('various_data', user) == {"1": "bar"}
+
+    def test_structured_dict_key_value_serialize(self, user):
+        user.various_data = {1: decimal.Decimal('1')}
+        field = fields.Dict(keys=fields.Str, values=fields.Decimal)
+        assert field.serialize('various_data', user) == {"1": 1}
+
+    def test_structured_dict_validates(self, user):
+        user.various_data = {"foo": "bar"}
+        field = fields.Dict(values=fields.Decimal)
+        with pytest.raises(ValidationError):
+            field.serialize('various_data', user)
+
     def test_url_field_serialize_none(self, user):
         user.homepage = None
         field = fields.Url()