Repository: oxan/djangorestframework-dataclasses

Issue:
Cannot use with PATCH method
The update method will not work with the PATCH method and partially updating.
Error: `TypeError: __init__() missing 3 required positional arguments: 'id', 'port', and 'cipher'`
It lists all the missing attributes and says they're required to init the dataclass.

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index ae8ca85..fd0f3e8 100644
--- a/README.rst
+++ b/README.rst
@@ -4,8 +4,8 @@ Dataclasses serializer
 A `dataclasses <https://docs.python.org/3/library/dataclasses.html>`__ serializer for the `Django REST Framework
 <http://www.django-rest-framework.org/>`__.
 
-.. image:: https://github.com/oxan/djangorestframework-dataclasses/workflows/CI/badge.svg
-   :target: https://github.com/oxan/djangorestframework-dataclasses/actions?query=workflow%3ACI   
+.. image:: https://travis-ci.org/oxan/djangorestframework-dataclasses.svg?branch=master
+   :target: https://travis-ci.org/oxan/djangorestframework-dataclasses
 .. image:: https://codecov.io/gh/oxan/djangorestframework-dataclasses/branch/master/graph/badge.svg
    :target: https://codecov.io/gh/oxan/djangorestframework-dataclasses
 .. image:: https://badge.fury.io/py/djangorestframework-dataclasses.svg
diff --git a/codecov.yml b/codecov.yml
index 8ada5e2..69cb760 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,12 +1,1 @@
 comment: false
-
-coverage:
-  status:
-    project:
-      default:
-        target: 90%
-        threshold: 2%
-    patch:
-      default:
-        target: 90%
-        threshold: 2%
diff --git a/rest_framework_dataclasses/serializers.py b/rest_framework_dataclasses/serializers.py
index 959ae1f..2c9cd9c 100644
--- a/rest_framework_dataclasses/serializers.py
+++ b/rest_framework_dataclasses/serializers.py
@@ -11,6 +11,7 @@ import rest_framework.serializers
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Model
 from django.utils.functional import cached_property
+from rest_framework.fields import empty
 from rest_framework.relations import HyperlinkedRelatedField, PrimaryKeyRelatedField
 from rest_framework.utils.field_mapping import get_relation_kwargs
 
@@ -20,6 +21,7 @@ from rest_framework_dataclasses.types import Dataclass
 
 
 # Define some types to make type hinting more readable
+AnyT = TypeVar('AnyT')
 KWArgs = Dict[str, Any]
 SerializerField = rest_framework.fields.Field
 SerializerFieldDefinition = Tuple[Type[SerializerField], KWArgs]
@@ -105,15 +107,30 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
 
     # Default `create` and `update` behavior...
 
+    def _strip_empty_sentinels(self, x: AnyT) -> AnyT:
+        """
+        Recursively strip the `empty` sentinel values from dataclasses, replacing them with their fields default value.
+        """
+        if dataclasses.is_dataclass(x) and not isinstance(x, type):
+            values = {field.name: self._strip_empty_sentinels(getattr(x, field.name)) for field in dataclasses.fields(x)
+                      if getattr(x, field.name) is not empty}
+            return type(x)(**values)
+        if isinstance(x, list):
+            return [self._strip_empty_sentinels(item) for item in x]
+        if isinstance(x, dict):
+            return {key: self._strip_empty_sentinels(value) for key, value in x.items()}
+        return x
+
     def create(self, validated_data: T) -> T:
-        return validated_data
+        # Create a new instance with all the `empty` sentinel values replaced by their defaults.
+        return self._strip_empty_sentinels(validated_data)
 
     def update(self, instance: T, validated_data: T) -> T:
-        for name, field in self.dataclass_definition.fields.items():
-            # Don't overwrite fields that weren't present in the serialized representation.
-            # noinspection PyProtectedMember
-            if name not in validated_data._unsupplied_fields:
-                setattr(instance, name, getattr(validated_data, name))
+        for name in self.dataclass_definition.fields.keys():
+            # Only update fields that have been set in the serialized representation.
+            value = getattr(validated_data, name)
+            if value is not empty:
+                setattr(instance, name, self._strip_empty_sentinels(value))
 
         return instance
 
@@ -135,13 +152,8 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
         validated_data = dataclasses.replace(self.validated_data, **kwargs)
 
         if self.instance is not None:
-            # Remove fields supplied by kwargs from the list of unsupplied fields that shouldn't be overwritten.
-            # noinspection PyProtectedMember
-            validated_data._unsupplied_fields = [f for f in self.validated_data._unsupplied_fields if f not in kwargs]
-
             self.instance = self.update(self.instance, validated_data)
         else:
-            # We don't need to bother with unsupplied fields here, as there's nothing to overwrite anyway.
             self.instance = self.create(validated_data)
 
         assert self.instance is not None, (
@@ -505,12 +517,10 @@ class DataclassSerializer(rest_framework.serializers.Serializer, Generic[T]):
         Convert a dictionary representation of the dataclass containing only primitive values to a dataclass instance.
         """
         native_values = super(DataclassSerializer, self).to_internal_value(data)
-        dataclass_type = self.dataclass_definition.dataclass_type
-        instance = dataclass_type(**native_values)
+        empty_values = {key: empty for key in self.dataclass_definition.fields.keys() if key not in native_values}
 
-        # Keep a list of the fields that have not been supplied in the serialized representation, so that we can avoid
-        # overwriting existing values for them in update().
-        instance._unsupplied_fields = [f for f in self.dataclass_definition.fields.keys() if f not in native_values]
+        dataclass_type = self.dataclass_definition.dataclass_type
+        instance = dataclass_type(**native_values, **empty_values)
 
         return instance
 


Test patch:
diff --git a/tests/test_functional.py b/tests/test_functional.py
index 0a1d261..08a9476 100644
--- a/tests/test_functional.py
+++ b/tests/test_functional.py
@@ -5,9 +5,9 @@ import decimal
 import typing
 import uuid
 
-from unittest import TestCase
+from unittest import TestCase, expectedFailure
 
-from rest_framework import fields, serializers
+from rest_framework import fields
 
 from rest_framework_dataclasses.serializers import DataclassSerializer
 from rest_framework_dataclasses.types import Literal
@@ -138,12 +138,36 @@ class EmptyPersonTest(TestCase, FunctionalTestMixin):
         'full_name': 'Alice',
         'email': 'alice@example.com',
         'phone': [],
+        'favorite_pet': None,
+    }
+    representation_readonly = {
+        'slug': 'Alice',
         'pets': None,
         'birth_date': None,
         'age': None,
-        'favorite_pet': None,
         'movie_ratings': None
     }
-    representation_readonly = {
-        'slug': 'Alice'
-    }
+
+
+class PartialPersonTest(TestCase):
+    def test_update(self):
+        input_instance = Person(
+            id=uuid.UUID('28ee3ae5-480b-46bd-9ae4-c61cf8341b95'),
+            name='Alice',
+            email='alice@example.com',
+            phone=['+31-6-1234-5678', '+31-20-123-4567'],
+        )
+
+        partial_representation = {
+            'full_name': 'Bob',
+            'favorite_pet': {'animal': 'cat', 'name': 'Luna'}
+        }
+
+        expected_output = dataclasses.replace(input_instance, name='Bob', favorite_pet=Pet(animal='cat', name='Luna'))
+
+        serializer = PersonSerializer(instance=input_instance, data=partial_representation, partial=True)
+        serializer.is_valid(raise_exception=True)
+        output_instance = serializer.save()
+
+        self.assertIs(output_instance, input_instance)
+        self.assertEqual(output_instance, expected_output)
diff --git a/tests/test_serializers.py b/tests/test_serializers.py
index c28d20b..aeaca52 100644
--- a/tests/test_serializers.py
+++ b/tests/test_serializers.py
@@ -7,6 +7,8 @@ from unittest import TestCase
 
 from django.core.exceptions import ImproperlyConfigured
 from rest_framework import fields, serializers
+from rest_framework.fields import empty
+
 from rest_framework_dataclasses.serializers import DataclassSerializer
 
 
@@ -20,6 +22,12 @@ class Person:
         return 1
 
 
+@dataclasses.dataclass
+class Group:
+    leader: Person
+    people: typing.List[Person]
+
+
 class SerializerTest(TestCase):
     def create_serializer(self, dataclass=None, arguments=None, declared=None, meta=None) -> DataclassSerializer:
         arguments = arguments or {}
@@ -62,52 +70,75 @@ class SerializerTest(TestCase):
         with self.assertRaises(ValueError):
             definition = self.create_serializer(str).dataclass_definition
 
+    def test_strip_empty(self):
+        ser = self.create_serializer(Person)
+
+        # simple
+        in_data = Person(name='Alice', length=123, birth_date=empty)
+        out_data = Person(name='Alice', length=123, birth_date=None)
+        self.assertEqual(ser._strip_empty_sentinels(in_data), out_data)
+
+        # nested dataclasses
+        in_data = Group(leader=Person(name='Alice', length=123, birth_date=empty),
+                        people=[Person(name='Bob', length=456, birth_date=empty)])
+        out_data = Group(leader=Person(name='Alice', length=123),
+                         people=[Person(name='Bob', length=456)])
+        self.assertEqual(ser._strip_empty_sentinels(in_data), out_data)
+
     def test_save(self):
-        def mock_person_serializer(data, instance=None):
+        def mock_save(validated_data, instance=None, **kwargs):
             serializer = self.create_serializer(Person)
             serializer._errors = []
-            serializer._validated_data = data
-            if not hasattr(serializer._validated_data, '_unsupplied_fields'):
-                serializer._validated_data._unsupplied_fields = []
+            serializer._validated_data = validated_data
             if instance:
                 serializer.instance = instance
-            return serializer
-
-        alice = Person(name='Alice', length=123)
+            return serializer.save(**kwargs)
 
         # regular create
-        ser = mock_person_serializer(alice)
-        self.assertEqual(ser.save(), alice)
+        in_data = Person(name='Alice', length=123)
+        out_data = mock_save(in_data)
+        self.assertEqual(in_data, out_data)
 
         # regular update
-        inst = dataclasses.replace(alice)
-        ser = mock_person_serializer(alice, instance=inst)
-        self.assertIs(ser.save(), inst)
-        self.assertEqual(ser.save(), alice)
+        inst = Person(name='Alice', length=123)
+        in_data = dataclasses.replace(inst, length=456)
+        out_data = mock_save(in_data, instance=inst)
+        self.assertIs(out_data, inst)
+        self.assertEqual(out_data, in_data)
 
         # create with kwargs
-        ser = mock_person_serializer(alice)
-        self.assertEqual(ser.save(length=456), dataclasses.replace(alice, length=456))
+        in_data = Person(name='Alice', length=123)
+        out_data = mock_save(in_data, length=456)
+        self.assertEqual(out_data, dataclasses.replace(in_data, length=456))
 
         # update with kwargs
-        inst = dataclasses.replace(alice, name='Bob', length=789)
-        ser = mock_person_serializer(alice, instance=inst)
-        self.assertIs(ser.save(length=456), inst)
-        self.assertEqual(ser.save(length=456), dataclasses.replace(alice, length=456))
-
-        # full update with set of field to default value
-        inst = dataclasses.replace(alice, birth_date=datetime.datetime(2020, 2, 2))
-        ser = mock_person_serializer(alice, instance=inst)
-        self.assertIs(ser.save(), inst)
-        self.assertEqual(ser.save().birth_date, None)
-
-        # partial update with missing optional field
-        data = dataclasses.replace(alice)
-        data._unsupplied_fields = ["birth_date"]
-        inst = dataclasses.replace(alice, birth_date=datetime.datetime(2020, 2, 2))
-        ser = mock_person_serializer(data, instance=inst)
-        self.assertIs(ser.save(), inst)
-        self.assertEqual(ser.save().birth_date, datetime.datetime(2020, 2, 2))
+        inst = Person(name='Bob', length=789)
+        in_data = Person(name='Alice', length=456)
+        out_data = mock_save(in_data, instance=inst, length=456)
+        self.assertIs(out_data, inst)
+        self.assertEqual(out_data, dataclasses.replace(in_data, length=456))
+
+        # full update with explicit reset of field to default value
+        inst = Person(name='Alice', length=123, birth_date=datetime.datetime(2020, 2, 2))
+        in_data = Person(name='Alice', length=123, birth_date=None)
+        out_data = mock_save(in_data, instance=inst)
+        self.assertIs(out_data, inst)
+        self.assertEqual(out_data.birth_date, None)
+
+        # full update with unsupplied optional field
+        inst = Person(name='Alice', length=123, birth_date=datetime.datetime(2020, 2, 2))
+        in_data = Person(name='Alice', length=123, birth_date=empty)
+        out_data = mock_save(in_data, instance=inst)
+        self.assertIs(out_data, inst)
+        self.assertEqual(out_data.birth_date, datetime.datetime(2020, 2, 2))
+
+        # partial update with missing required field
+        inst = Person(name='Alice', length=123)
+        in_data = Person(name='Bob', length=empty)
+        out_data = mock_save(in_data, instance=inst)
+        self.assertIs(out_data, inst)
+        self.assertEqual(out_data.name, 'Bob')
+        self.assertEqual(out_data.length, 123)
 
         # not validated
         with self.assertRaises(AssertionError):
@@ -314,21 +345,24 @@ class SerializerTest(TestCase):
         with self.assertRaises(AssertionError):
             self.create_serializer(meta={'readonly_fields': ['name']}).get_extra_kwargs()
 
-    def test_to_internal_representation(self):
+    def test_to_internal_value(self):
         ser = self.create_serializer(Person)
 
         # simple
         value = ser.to_internal_value({'name': 'Alice', 'length': 123, 'birth_date': '2020-02-02'})
         self.assertEqual(value, Person(name='Alice', length=123, birth_date=datetime.date(2020, 2, 2)))
-        self.assertEqual(value._unsupplied_fields, [])
 
         # unsupplied fields
         value = ser.to_internal_value({'name': 'Alice', 'length': 123})
-        self.assertEqual(value, Person(name='Alice', length=123))
-        self.assertEqual(value._unsupplied_fields, ["birth_date"])
+        self.assertEqual(value, Person(name='Alice', length=123, birth_date=empty))
+
+        # unsupplied required fields (in partial mode)
+        ser = self.create_serializer(Person, arguments={'partial': True})
+        value = ser.to_internal_value({'name': 'Alice'})
+        self.assertEqual(value, Person(name='Alice', length=empty, birth_date=empty))
 
         # nested
         simple = dataclasses.make_dataclass('child', [('value', str)])
         parent = dataclasses.make_dataclass('parent', [('container', simple)])
-        self.assertEqual(self.create_serializer(parent).to_internal_value({'container': {'value': 'a'}}),
-                         parent(container=simple(value='a')))
\ No newline at end of file
+        value = self.create_serializer(parent).to_internal_value({'container': {'value': 'a'}})
+        self.assertEqual(value, parent(container=simple(value='a')))