Repository: Scille/umongo (python)

Issue:
DictField limitations
`DictField` suffers from a few limitations.

- Must be marked as modified manually
- Does not deserialize values into objects (no Schema)
- Can't use non-string keys

It might be useful to have a schema-less structure, but sometimes, I'd like to have `DictField` enforce a schema. Maybe we could add it extra features or create a `TypedDictField` (or `StrcturedDictField`, I don't know about the naming).

See [this discussion](https://github.com/marshmallow-code/marshmallow/issues/483) in Marshmallow about nesting a `Schema` into a `Dict`.

[This comment](https://github.com/marshmallow-code/marshmallow/issues/483#issuecomment-285936314) illustrates the limits of `DictField`.

The enhancement proposal is to add the possibility to do this:

```python
    fields.Dict(keys=fields.Str(), values=fields.Nested(SomeSchema))
```

Benefits:

- Serialization/deserialization of keys and values
- Allows to use non-string keys (hashable and serializable)
- Keep track of is_modified, no need to mark the field as modified manually

Note: uMongo does not complain if a Dict is passed non-string key, but pymongo will raise an `Exception` at commit time. This could be protected by a validator at assignment time in current `DictField`. Obviously, adding the feature described above would solve this by allowing non-string keys.

Not sure which approach would be best. Try to solve upstream https://github.com/marshmallow-code/marshmallow/issues/483 and ultimately if rejected implement `TypedDictField` as a marshmallow-bonus field, or create `TypedDictField` here as a proof-of-concept and try to have it integrated upstream. The more we can provide upstream, the best, and I saw no objection to the idea, there.

Pull request:
Use fields for keys/values in DictField
Closes #99.

Patch:
diff --git a/umongo/builder.py b/umongo/builder.py
index cbe8003..954258f 100644
--- a/umongo/builder.py
+++ b/umongo/builder.py
@@ -12,7 +12,7 @@ from .embedded_document import (
 from .exceptions import DocumentDefinitionError, NotRegisteredDocumentError
 from .schema import Schema, on_need_add_id_field, add_child_field
 from .indexes import parse_index
-from .fields import ListField, EmbeddedField
+from .fields import ListField, DictField, EmbeddedField
 
 
 def camel_to_snake(name):
@@ -200,7 +200,12 @@ class BaseBuilder:
         field.instance = self.instance
         if isinstance(field, ListField):
             self._patch_field(field.inner)
-        if isinstance(field, EmbeddedField):
+        elif isinstance(field, DictField):
+            if field.key_field:
+                self._patch_field(field.key_field)
+            if field.value_field:
+                self._patch_field(field.value_field)
+        elif isinstance(field, EmbeddedField):
             for embedded_field in field.schema.fields.values():
                 self._patch_field(embedded_field)
 
diff --git a/umongo/data_objects.py b/umongo/data_objects.py
index 5f551fb..c011a59 100644
--- a/umongo/data_objects.py
+++ b/umongo/data_objects.py
@@ -66,42 +66,88 @@ class List(BaseDataObject, list):
         return '<object %s.%s(%s)>' % (
             self.__module__, self.__class__.__name__, list(self))
 
-    def set_modified(self):
-        self._modified = True
-
     def is_modified(self):
         if self._modified:
             return True
-        if len(self) and isinstance(self[0], BaseDataObject):
-            # Recursive handling needed
+        if self and isinstance(self[0], BaseDataObject):
             return any(obj.is_modified() for obj in self)
         return False
 
+    def set_modified(self):
+        self._modified = True
+
     def clear_modified(self):
         self._modified = False
         if len(self) and isinstance(self[0], BaseDataObject):
-            # Recursive handling needed
             for obj in self:
                 obj.clear_modified()
 
 
-# TODO: Dict is to much raw: you need to use `set_modified` by hand !
 class Dict(BaseDataObject, dict):
 
-    __slots__ = ('_modified', )
+    __slots__ = ('key_field', 'value_field', '_modified')
 
-    def __init__(self, *args, **kwargs):
-        self._modified = False
+    def __init__(self, key_field, value_field, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self._modified = False
+        self.key_field = key_field
+        self.value_field = value_field
+
+    def __setitem__(self, key, obj):
+        key = self.key_field.deserialize(key) if self.key_field else key
+        obj = self.value_field.deserialize(obj) if self.value_field else obj
+        super().__setitem__(key, obj)
+        self.set_modified()
+
+    def __delitem__(self, key):
+        super().__delitem__(key)
+        self.set_modified()
+
+    def pop(self, *args, **kwargs):
+        ret = super().pop(*args, **kwargs)
+        self.set_modified()
+        return ret
+
+    def popitem(self, *args, **kwargs):
+        ret = super().popitem(*args, **kwargs)
+        self.set_modified()
+        return ret
+
+    def setdefault(self, key, obj=None):
+        key = self.key_field.deserialize(key) if self.key_field else key
+        obj = self.value_field.deserialize(obj) if self.value_field else obj
+        ret = super().setdefault(key, obj)
+        self.set_modified()
+        return ret
+
+    def update(self, other):
+        new = {
+            self.key_field.deserialize(k) if self.key_field else k:
+            self.value_field.deserialize(v) if self.value_field else v
+            for k, v in other.items()
+        }
+        super().update(new)
+        self.set_modified()
+
+    def __repr__(self):
+        return '<object %s.%s(%s)>' % (
+            self.__module__, self.__class__.__name__, dict(self))
 
     def is_modified(self):
-        return self._modified
+        if self._modified:
+            return True
+        if self and any(isinstance(v, BaseDataObject) for v in self.values()):
+            return any(obj.is_modified() for obj in self.values())
+        return False
 
     def set_modified(self):
         self._modified = True
 
     def clear_modified(self):
         self._modified = False
+        if self and any(isinstance(v, BaseDataObject) for v in self.values()):
+            for obj in self.values():
+                obj.clear_modified()
 
 
 class Reference:
diff --git a/umongo/fields.py b/umongo/fields.py
index 38b73d7..fec5a1f 100644
--- a/umongo/fields.py
+++ b/umongo/fields.py
@@ -1,3 +1,4 @@
+import collections
 import datetime as dt
 
 from bson import DBRef, ObjectId, Decimal128
@@ -16,8 +17,6 @@ from .i18n import gettext as _
 __all__ = (
     # 'RawField',
     # 'MappingField',
-    'DictField',
-    'ListField',
     # 'TupleField',
     'StringField',
     'UUIDField',
@@ -38,6 +37,8 @@ __all__ = (
     'StrField',
     'BoolField',
     'IntField',
+    'DictField',
+    'ListField',
     'ConstantField',
     # 'PluckField'
     'ObjectIdField',
@@ -54,102 +55,6 @@ __all__ = (
 #     pass
 
 
-class DictField(BaseField, ma_fields.Dict):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        def cast_value_or_callable(value):
-            if value is missing:
-                return missing
-            if callable(value):
-                return lambda: Dict(value())
-            return Dict(value)
-
-        self.default = cast_value_or_callable(self.default)
-        self.missing = cast_value_or_callable(self.missing)
-
-    def _deserialize(self, value, attr, data, **kwargs):
-        value = super()._deserialize(value, attr, data, **kwargs)
-        return Dict(value)
-
-    def _serialize_to_mongo(self, obj):
-        if obj is None:
-            return missing
-        return dict(obj)
-
-    def _deserialize_from_mongo(self, value):
-        if value:
-            return Dict(value)
-        return Dict()
-
-
-class ListField(BaseField, ma_fields.List):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        def cast_value_or_callable(inner, value):
-            if value is missing:
-                return missing
-            if callable(value):
-                return lambda: List(inner, value())
-            return List(inner, value)
-
-        self.default = cast_value_or_callable(self.inner, self.default)
-        self.missing = cast_value_or_callable(self.inner, self.missing)
-
-    def _deserialize(self, value, attr, data, **kwargs):
-        ret = List(self.inner, super()._deserialize(value, attr, data, **kwargs))
-        return ret
-
-    def _serialize_to_mongo(self, obj):
-        if obj is None:
-            return missing
-        return [self.inner.serialize_to_mongo(each) for each in obj]
-
-    def _deserialize_from_mongo(self, value):
-        if value:
-            return List(
-                self.inner,
-                [self.inner.deserialize_from_mongo(each) for each in value]
-            )
-        return List(self.inner)
-
-    def map_to_field(self, mongo_path, path, func):
-        """Apply a function to every field in the schema
-        """
-        func(mongo_path, path, self.inner)
-        if hasattr(self.inner, 'map_to_field'):
-            self.inner.map_to_field(mongo_path, path, func)
-
-    def as_marshmallow_field(self, params=None, mongo_world=False, **kwargs):
-        # Overwrite default `as_marshmallow_field` to handle deserialization
-        # difference (`_id` vs `id`)
-        field_kwargs = self._extract_marshmallow_field_params(mongo_world)
-        if params:
-            inner_params = params.pop('params', None)
-            field_kwargs.update(params)
-        else:
-            inner_params = None
-        inner_ma_schema = self.inner.as_marshmallow_field(
-            mongo_world=mongo_world, params=inner_params, **kwargs)
-        return ma_fields.List(inner_ma_schema, **field_kwargs)
-
-    def _required_validate(self, value):
-        if value is missing or not hasattr(self.inner, '_required_validate'):
-            return
-        required_validate = self.inner._required_validate
-        errors = {}
-        for i, sub_value in enumerate(value):
-            try:
-                required_validate(sub_value)
-            except ValidationError as exc:
-                errors[i] = exc.messages
-        if errors:
-            raise ValidationError(errors)
-
-
 class StringField(BaseField, ma_fields.String):
     pass
 
@@ -271,6 +176,143 @@ class ConstantField(BaseField, ma_fields.Constant):
     pass
 
 
+class DictField(BaseField, ma_fields.Dict):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        def cast_value_or_callable(key_field, value_field, value):
+            if value is missing:
+                return missing
+            if callable(value):
+                return lambda: Dict(key_field, value_field, value())
+            return Dict(key_field, value_field, value)
+
+        self.default = cast_value_or_callable(self.key_field, self.value_field, self.default)
+        self.missing = cast_value_or_callable(self.key_field, self.value_field, self.missing)
+
+    def _deserialize(self, value, attr, data, **kwargs):
+        value = super()._deserialize(value, attr, data, **kwargs)
+        return Dict(self.key_field, self.value_field, value)
+
+    def _serialize_to_mongo(self, obj):
+        if obj is None:
+            return missing
+        return {
+            self.key_field.serialize_to_mongo(k) if self.key_field else k:
+            self.value_field.serialize_to_mongo(v) if self.value_field else v
+            for k, v in obj.items()
+        }
+
+    def _deserialize_from_mongo(self, value):
+        if value:
+            return Dict(
+                self.key_field,
+                self.value_field,
+                {
+                    self.key_field.deserialize_from_mongo(k) if self.key_field else k:
+                    self.value_field.deserialize_from_mongo(v) if self.value_field else v
+                    for k, v in value.items()
+                }
+            )
+        return Dict(self.key_field, self.value_field)
+
+    def as_marshmallow_field(self, params=None, mongo_world=False, **kwargs):
+        # Overwrite default `as_marshmallow_field` to handle deserialization
+        # difference (`_id` vs `id`)
+        field_kwargs = self._extract_marshmallow_field_params(mongo_world)
+        if params:
+            inner_params = params.pop('params', None)
+            field_kwargs.update(params)
+        else:
+            inner_params = None
+        if self.value_field:
+            inner_ma_schema = self.value_field.as_marshmallow_field(
+                mongo_world=mongo_world, params=inner_params, **kwargs)
+        else:
+            inner_ma_schema = None
+        return ma_fields.Dict(self.key_field, inner_ma_schema, **field_kwargs)
+
+    def _required_validate(self, value):
+        if value is missing or not hasattr(self.value_field, '_required_validate'):
+            return
+        required_validate = self.value_field._required_validate
+        errors = collections.defaultdict(dict)
+        for key, val in value.items():
+            try:
+                required_validate(val)
+            except ValidationError as exc:
+                errors[key]["value"] = exc.messages
+        if errors:
+            raise ValidationError(errors)
+
+
+class ListField(BaseField, ma_fields.List):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        def cast_value_or_callable(inner, value):
+            if value is missing:
+                return missing
+            if callable(value):
+                return lambda: List(inner, value())
+            return List(inner, value)
+
+        self.default = cast_value_or_callable(self.inner, self.default)
+        self.missing = cast_value_or_callable(self.inner, self.missing)
+
+    def _deserialize(self, value, attr, data, **kwargs):
+        value = super()._deserialize(value, attr, data, **kwargs)
+        return List(self.inner, value)
+
+    def _serialize_to_mongo(self, obj):
+        if obj is None:
+            return missing
+        return [self.inner.serialize_to_mongo(each) for each in obj]
+
+    def _deserialize_from_mongo(self, value):
+        if value:
+            return List(
+                self.inner,
+                [self.inner.deserialize_from_mongo(each) for each in value]
+            )
+        return List(self.inner)
+
+    def map_to_field(self, mongo_path, path, func):
+        """Apply a function to every field in the schema
+        """
+        func(mongo_path, path, self.inner)
+        if hasattr(self.inner, 'map_to_field'):
+            self.inner.map_to_field(mongo_path, path, func)
+
+    def as_marshmallow_field(self, params=None, mongo_world=False, **kwargs):
+        # Overwrite default `as_marshmallow_field` to handle deserialization
+        # difference (`_id` vs `id`)
+        field_kwargs = self._extract_marshmallow_field_params(mongo_world)
+        if params:
+            inner_params = params.pop('params', None)
+            field_kwargs.update(params)
+        else:
+            inner_params = None
+        inner_ma_schema = self.inner.as_marshmallow_field(
+            mongo_world=mongo_world, params=inner_params, **kwargs)
+        return ma_fields.List(inner_ma_schema, **field_kwargs)
+
+    def _required_validate(self, value):
+        if value is missing or not hasattr(self.inner, '_required_validate'):
+            return
+        required_validate = self.inner._required_validate
+        errors = {}
+        for i, sub_value in enumerate(value):
+            try:
+                required_validate(sub_value)
+            except ValidationError as exc:
+                errors[i] = exc.messages
+        if errors:
+            raise ValidationError(errors)
+
+
 # Aliases
 URLField = UrlField
 StrField = StringField


Test patch:
diff --git a/tests/test_data_proxy.py b/tests/test_data_proxy.py
index 41ac6f1..ee053fa 100644
--- a/tests/test_data_proxy.py
+++ b/tests/test_data_proxy.py
@@ -462,16 +462,23 @@ class TestDataProxy(BaseTest):
 
         class MySchema(EmbeddedSchema):
             # EmbeddedField need instance to retrieve implementation
-            listed = fields.ListField(fields.EmbeddedField(MyEmbedded, instance=self.instance))
-            embedded = fields.EmbeddedField(MyEmbedded, instance=self.instance)
             required = fields.IntField(required=True)
+            embedded = fields.EmbeddedField(MyEmbedded, instance=self.instance)
+            listed = fields.ListField(fields.EmbeddedField(MyEmbedded, instance=self.instance))
+            dicted = fields.DictField(
+                values=fields.EmbeddedField(MyEmbedded, instance=self.instance))
 
         MyDataProxy = data_proxy_factory('My', MySchema())
         d = MyDataProxy()
 
-        d.load({'embedded': {'required': 42}, 'required': 42, 'listed': [{'required': 42}]})
+        d.load({
+            'required': 42,
+            'embedded': {'required': 42},
+            'listed': [{'required': 42}],
+            'dicted': {'a': {'required': 42}}
+        })
         d.required_validate()
-        # Empty list should not trigger required if embedded field has required fields
+        # Empty list/dict should not trigger required if embedded field has required fields
         d.load({'embedded': {'required': 42}, 'required': 42})
         d.required_validate()
 
@@ -489,10 +496,13 @@ class TestDataProxy(BaseTest):
             d.required_validate()
         assert exc.value.messages == {'embedded': {'required': ['Missing data for required field.']}}
 
-        d.load({'embedded': {'required': 42}, 'required': 42, 'listed': [{}]})
+        d.load({'embedded': {'required': 42}, 'required': 42, 'listed': [{}], 'dicted': {'a': {}}})
         with pytest.raises(ValidationError) as exc:
             d.required_validate()
-        assert exc.value.messages == {'listed': {0: {'required': ['Missing data for required field.']}}}
+        assert exc.value.messages == {
+            'listed': {0: {'required': ['Missing data for required field.']}},
+            'dicted': {'a': {'value': {'required': ['Missing data for required field.']}}},
+        }
 
     def test_unkown_field_in_db(self):
         class MySchema(EmbeddedSchema):
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 21c5ff9..b4f056d 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -8,7 +8,7 @@ from bson import ObjectId, DBRef, Decimal128
 from marshmallow import ValidationError, missing
 
 from umongo.data_proxy import data_proxy_factory
-from umongo import Document, EmbeddedDocument, Schema, EmbeddedSchema, fields, Reference
+from umongo import Document, EmbeddedDocument, Schema, EmbeddedSchema, fields, Reference, validate
 from umongo.data_objects import List, Dict
 
 from .common import BaseTest
@@ -45,8 +45,10 @@ class TestRequired(BaseTest):
             embedded = fields.EmbeddedField(MyEmbedded)
             embedded_required = fields.EmbeddedField(MyEmbedded, required=True)
             embedded_list = fields.ListField(fields.EmbeddedField(MyEmbedded))
+            embedded_dict = fields.DictField(values=fields.EmbeddedField(MyEmbedded))
 
-        MyDoc(embedded={}, embedded_list=[{}])  # Required fields are check on commit
+        # Required fields are checked on commit
+        MyDoc(embedded={}, embedded_list=[{}], embedded_dict={'a': {}})
         # Don't check required fields in not required missing embedded
         MyDoc(embedded_required={'required_field': 42}).required_validate()
         # Now trigger required fails
@@ -57,17 +59,32 @@ class TestRequired(BaseTest):
             MyDoc(embedded_required={'optional_field': 1}).required_validate()
         assert exc.value.messages == {'embedded_required': {'required_field': ['Missing data for required field.']}}
         with pytest.raises(ValidationError) as exc:
-            MyDoc(embedded={'optional_field': 1},
-                  embedded_required={'required_field': 42}).required_validate()
+            MyDoc(
+                embedded={'optional_field': 1},
+                embedded_required={'required_field': 42}
+            ).required_validate()
         assert exc.value.messages == {'embedded': {'required_field': ['Missing data for required field.']}}
         with pytest.raises(ValidationError) as exc:
-            MyDoc(embedded={'required_field': 1}, embedded_list=[{'optional_field': 1}], embedded_required={'required_field': 42}).required_validate()
-        assert exc.value.messages == {'embedded_list': {0: {'required_field': ['Missing data for required field.']}}}
+            MyDoc(
+                embedded={'required_field': 1},
+                embedded_list=[{'optional_field': 1}],
+                embedded_dict={'a': {'optional_field': 1}},
+                embedded_required={'required_field': 42}
+            ).required_validate()
+        assert exc.value.messages == {
+            'embedded_list': {0: {'required_field': ['Missing data for required field.']}},
+            'embedded_dict': {'a': {'value': {'required_field': ['Missing data for required field.']}}},
+        }
 
         # Check valid constructions
-        doc = MyDoc(embedded={'required_field': 1}, embedded_list=[], embedded_required={'required_field': 42})
+        doc = MyDoc(embedded={'required_field': 1}, embedded_list=[], embedded_dict={}, embedded_required={'required_field': 42})
         doc.required_validate()
-        doc = MyDoc(embedded={'required_field': 1}, embedded_list=[{'required_field': 1}], embedded_required={'required_field': 42})
+        doc = MyDoc(
+            embedded={'required_field': 1},
+            embedded_list=[{'required_field': 1}],
+            embedded_dict={'a': {'required_field': 1}},
+            embedded_required={'required_field': 42},
+        )
         doc.required_validate()
 
 
@@ -227,6 +244,13 @@ class TestFields(BaseTest):
 
         class MySchema(Schema):
             dict = fields.DictField(attribute='in_mongo_dict', allow_none=True)
+            kdict = fields.DictField(keys=fields.StringField(validate=validate.Length(0, 1)))
+            vdict = fields.DictField(values=fields.IntField(validate=validate.Range(max=5)))
+            kvdict = fields.DictField(
+                keys=fields.StringField(validate=validate.Length(0, 1)),
+                values=fields.IntField(validate=validate.Range(max=5))
+            )
+            dtdict = fields.DictField(values=fields.DateTimeField)
 
         MyDataProxy = data_proxy_factory('My', MySchema())
         d = MyDataProxy()
@@ -237,18 +261,22 @@ class TestFields(BaseTest):
         assert d.get('dict') == {'a': 1, 'b': {'c': True}}
         assert d.to_mongo() == {'in_mongo_dict': {'a': 1, 'b': {'c': True}}}
 
-        # Must manually set_dirty to take the changes into account
         dict_ = d.get('dict')
         dict_['a'] = 1
-        assert d.to_mongo(update=True) is None
-        dict_.set_modified()
         assert d.to_mongo(update=True) == {'$set': {'in_mongo_dict': {'a': 1, 'b': {'c': True}}}}
         dict_.clear_modified()
         assert d.to_mongo(update=True) is None
 
+        # Test repr readability
+        repr_d = repr(d.get('dict'))
+        assert any(
+            repr_d == "<object umongo.data_objects.Dict({})>".format(d)
+            for d in ("{'a': 1, 'b': {'c': True}}", "{'b': {'c': True}, 'a': 1}")
+        )
+
         d2 = MyDataProxy({'dict': {'a': 1, 'b': {'c': True}}})
         assert d2.to_mongo() == {'in_mongo_dict': {'a': 1, 'b': {'c': True}}}
-
+        assert d2.to_mongo(update=True) == {'$set': {'in_mongo_dict': {'a': 1, 'b': {'c': True}}}}
         d2.set('dict', {})
         assert d2.to_mongo() == {'in_mongo_dict': {}}
         assert d2.to_mongo(update=True) == {'$set': {'in_mongo_dict': {}}}
@@ -264,23 +292,38 @@ class TestFields(BaseTest):
 
         d3.from_mongo({'in_mongo_dict': {}})
         assert d3._data.get('in_mongo_dict') == {}
-        d3.get('dict')['field'] = 'value'
-        assert d3.to_mongo(update=True) is None
-        d3.get('dict').set_modified()
-        assert d3.to_mongo(update=True) == {'$set': {'in_mongo_dict': {'field': 'value'}}}
-        assert d3.to_mongo() == {'in_mongo_dict': {'field': 'value'}}
+        d3.get('dict')['c'] = 3
+        assert d3.to_mongo(update=True) == {'$set': {'in_mongo_dict': {'c': 3}}}
+        assert d3.to_mongo() == {'in_mongo_dict': {'c': 3}}
 
         d4 = MyDataProxy({'dict': None})
         assert d4.to_mongo() == {'in_mongo_dict': None}
         d4.from_mongo({'in_mongo_dict': None})
         assert d4.get('dict') is None
 
+        with pytest.raises(ValidationError) as exc:
+            MyDataProxy({'kdict': {'ab': 1}})
+        assert exc.value.messages == {'kdict': {'ab': {'key': ['Length must be between 0 and 1.']}}}
+        with pytest.raises(ValidationError) as exc:
+            MyDataProxy({'vdict': {'a': 9}})
+        assert exc.value.messages == {
+            'vdict': {'a': {'value': ['Must be less than or equal to 5.']}}}
+        with pytest.raises(ValidationError) as exc:
+            MyDataProxy({'kvdict': {'ab': 9}})
+        assert exc.value.messages == {'kvdict': {'ab': {
+            'key': ['Length must be between 0 and 1.'],
+            'value': ['Must be less than or equal to 5.']
+        }}}
+
+        d5 = MyDataProxy({'dtdict': {'a': "2016-08-06T00:00:00"}})
+        assert d5.to_mongo() == {'dtdict': {'a': dt.datetime(2016, 8, 6)}}
+
     def test_dict_default(self):
 
         class MySchema(Schema):
             # Passing a mutable as default is a bad idea in real life
-            d_dict = fields.DictField(default={'1': 1, '2': 2})
-            c_dict = fields.DictField(default=lambda: {'1': 1, '2': 2})
+            d_dict = fields.DictField(values=fields.IntField, default={'1': 1, '2': 2})
+            c_dict = fields.DictField(values=fields.IntField, default=lambda: {'1': 1, '2': 2})
 
         MyDataProxy = data_proxy_factory('My', MySchema())
         d = MyDataProxy()
@@ -302,6 +345,67 @@ class TestFields(BaseTest):
         assert isinstance(d.get('d_dict'), Dict)
         assert isinstance(d.get('c_dict'), Dict)
 
+    def test_complex_dict(self):
+
+        @self.instance.register
+        class MyEmbeddedDocument(EmbeddedDocument):
+            field = fields.IntField()
+
+        @self.instance.register
+        class ToRefDoc(Document):
+            pass
+
+        @self.instance.register
+        class MyDoc(Document):
+            embeds = fields.DictField(values=fields.EmbeddedField(MyEmbeddedDocument))
+            refs = fields.DictField(values=fields.ReferenceField(ToRefDoc))
+
+        MySchema = MyDoc.Schema
+
+        obj_id1 = ObjectId()
+        obj_id2 = ObjectId()
+        to_ref_doc1 = ToRefDoc.build_from_mongo(data={'_id': obj_id1})
+        MyDataProxy = data_proxy_factory('My', MySchema())
+        d = MyDataProxy()
+        d.load({
+            'embeds': {
+                'a': MyEmbeddedDocument(field=1),
+                'b': {'field': 2},
+            },
+            'refs': {
+                '1': to_ref_doc1,
+                '2': Reference(ToRefDoc, obj_id2),
+            }
+        })
+        assert d.to_mongo() == {
+            'embeds': {'a': {'field': 1}, 'b': {'field': 2}},
+            'refs': {'1': obj_id1, '2': obj_id2},
+        }
+        assert isinstance(d.get('embeds'), Dict)
+        assert isinstance(d.get('refs'), Dict)
+        for e in d.get('refs').values():
+            assert isinstance(e, Reference)
+        for e in d.get('embeds').values():
+            assert isinstance(e, MyEmbeddedDocument)
+        # Test dict modification as well
+        refs_dict = d.get('refs')
+        refs_dict.update({'3': to_ref_doc1, '4': Reference(ToRefDoc, obj_id2)})
+        for e in refs_dict.values():
+            assert isinstance(e, Reference)
+        embeds_dict = d.get('embeds')
+        embeds_dict.update({'c': MyEmbeddedDocument(field=3), 'd': {'field': 4}})
+        for e in embeds_dict.values():
+            assert isinstance(e, MyEmbeddedDocument)
+        # Modifying an EmbeddedDocument inside a dict should count a dict modification
+        d.clear_modified()
+        d.get('refs')['1'] = obj_id2
+        assert d.to_mongo(update=True) == {'$set': {'refs': {
+            '1': obj_id2, '2': obj_id2, '3': obj_id1, '4': obj_id2}}}
+        d.clear_modified()
+        d.get('embeds')['b'].field = 42
+        assert d.to_mongo(update=True) == {'$set': {'embeds': {
+            'a': {'field': 1}, 'b': {'field': 42}, 'c': {'field': 3}, 'd': {'field': 4}}}}
+
     def test_list(self):
 
         class MySchema(Schema):
diff --git a/tests/test_marshmallow.py b/tests/test_marshmallow.py
index 8708ac4..ed1c4c7 100644
--- a/tests/test_marshmallow.py
+++ b/tests/test_marshmallow.py
@@ -81,8 +81,10 @@ class TestMarshmallow(BaseTest):
         @self.instance.register
         class Bag(Document):
             id = fields.EmbeddedField(Accessory, attribute='_id', required=True)
-            names = fields.ListField(fields.StringField())
+            names = fields.ListField(fields.StringField)
             content = fields.ListField(fields.EmbeddedField(Accessory))
+            relations = fields.DictField(fields.StringField, fields.StringField)
+            inventory = fields.DictField(fields.StringField, fields.EmbeddedField(Accessory))
 
         ma_field = Bag.schema.fields['id'].as_marshmallow_field(params={
             'load_only': True,
@@ -100,6 +102,17 @@ class TestMarshmallow(BaseTest):
         assert ma_field.load_only is True
         assert ma_field.inner.required is True
         assert ma_field.inner.nested._declared_fields['value'].dump_only
+        ma_field = Bag.schema.fields['relations'].as_marshmallow_field(params={
+            'load_only': True,
+            'params': {'dump_only': True}})
+        assert ma_field.load_only is True
+        assert ma_field.value_field.dump_only is True
+        ma_field = Bag.schema.fields['inventory'].as_marshmallow_field(params={
+            'load_only': True,
+            'params': {'required': True, 'params': {'value': {'dump_only': True}}}})
+        assert ma_field.load_only is True
+        assert ma_field.value_field.required is True
+        assert ma_field.value_field.nested._declared_fields['value'].dump_only
 
     def test_pass_meta_attributes(self):
         @self.instance.register
@@ -111,6 +124,7 @@ class TestMarshmallow(BaseTest):
         class Bag(Document):
             id = fields.EmbeddedField(Accessory, attribute='_id', required=True)
             content = fields.ListField(fields.EmbeddedField(Accessory))
+            inventory = fields.DictField(fields.StringField, fields.EmbeddedField(Accessory))
 
         ma_schema = Bag.schema.as_marshmallow_schema(meta={'exclude': ('id',)})
         assert ma_schema.Meta.exclude == ('id',)
@@ -120,6 +134,9 @@ class TestMarshmallow(BaseTest):
         ma_schema = Bag.schema.as_marshmallow_schema(params={
             'content': {'params': {'meta': {'exclude': ('value',)}}}})
         assert ma_schema._declared_fields['content'].inner.nested.Meta.exclude == ('value',)
+        ma_schema = Bag.schema.as_marshmallow_schema(params={
+            'inventory': {'params': {'meta': {'exclude': ('value',)}}}})
+        assert ma_schema._declared_fields['inventory'].value_field.nested.Meta.exclude == ('value',)
 
         class DumpOnlyIdSchema(marshmallow.Schema):
             class Meta: