Repository: django-json-api/django-rest-framework-json-api

Issue:
PATCH to RelationshipView can cause data to be inadvertently deleted
**What**
Making a PATCH request to a relationship view should replace the entire relationship with the content of the PATCH. This behavior is provided by the package but in some instances can cause objects to be deleted that shouldn't be.

**How**
In the RelationshipView's patch method (https://github.com/django-json-api/django-rest-framework-json-api/blob/7ad660b2534bed3b30c64a55a0e79f59b3db1a70/rest_framework_json_api/views.py#L81), there are two code paths: if the relationship instance is a manager (ManyToMany or reverse ForeignKey) or not (a direct ForeignKey on the model). In the manager path, the intention is that all relationships are cleared and then re-created.

Currently, the patch method will validate the incoming data, delete all of the objects in the existing relationship (https://github.com/django-json-api/django-rest-framework-json-api/blob/7ad660b2534bed3b30c64a55a0e79f59b3db1a70/rest_framework_json_api/views.py#L89), and recreate the relationship with objects passed into the request.

I see 3 ways this code patch can be accessed: reverse ForeignKey with non-nullable ForeignKeys to this object, reverse ForeignKey with nullable ForeignKeys to this object, and a ManyToMany relationship from this object to other objects. Right now the code will actually delete the underlying objects in the relationship, which I think is only correct in one of those three cases (the reverse ForeignKey with non-nullable ForeignKeys to this object).

**Solution**
My proposed solution would replace the line that deletes the objects in the relationship with a function, called something like `remove_relationship`, which will check each of these cases and perform the correct action:
- **ManyToMany relationship**, the relationship should be cleared and then re-populated with the passed data
- **Reverse ForeignKey with nullable ForeignKeys**, the value of the ForeignKey on each object should be set to None
- **Reverse ForeignKey with non-nullable ForeignKeys**, those objects should be deleted as they are currently.

I don't have a patch for this right now which is why I'm just reporting it, but if no one else has time to tackle this I'll try to get something up that people can review.



Discussion:
Is it possible to make a contribution to this? If it is then some guidance would be helpful.
@Alig1493 I guess best way to start is to write a in [test_views.py](https://github.com/django-json-api/django-rest-framework-json-api/blob/master/example/tests/test_views.py) to reproduce this issue and try to fix it from there.
I've been looking at this issue investigating the relationship between Author and Comments. 
Comments has a nullable Author foreign key.

So I'm guessing when we define a new relationship between an existing Author object and a new Comment object using a patch request. the new comment should have the Author instance.

However after making the patch request the old comment gets deleted and a new relationship between the new comment and author gets created.

I wrote a test for this to investigate this issue and saw the list of comment objects before and after the patch request was made.

I wanted to make sure that this is the issue that's being discussed here before I try and see if I can find a way to solve the issue.

My test case output:
https://dpaste.de/5Jpr

Resolving patch:
diff --git a/rest_framework_json_api/views.py b/rest_framework_json_api/views.py
--- a/rest_framework_json_api/views.py
+++ b/rest_framework_json_api/views.py
@@ -251,6 +251,18 @@ def get(self, request, *args, **kwargs):
         serializer_instance = self._instantiate_serializer(related_instance)
         return Response(serializer_instance.data)
 
+    def remove_relationships(self, instance_manager, field):
+        field_object = getattr(instance_manager, field)
+
+        if field_object.null:
+            for obj in instance_manager.all():
+                setattr(obj, field_object.name, None)
+                obj.save()
+        else:
+            instance_manager.all().delete()
+
+        return instance_manager
+
     def patch(self, request, *args, **kwargs):
         parent_obj = self.get_object()
         related_instance_or_manager = self.get_related_instance()
@@ -261,7 +273,16 @@ def patch(self, request, *args, **kwargs):
                 data=request.data, model_class=related_model_class, many=True
             )
             serializer.is_valid(raise_exception=True)
-            related_instance_or_manager.all().delete()
+
+            # for to one
+            if hasattr(related_instance_or_manager, "field"):
+                related_instance_or_manager = self.remove_relationships(
+                    instance_manager=related_instance_or_manager, field="field")
+            # for to many
+            else:
+                related_instance_or_manager = self.remove_relationships(
+                    instance_manager=related_instance_or_manager, field="target_field")
+
             # have to set bulk to False since data isn't saved yet
             class_name = related_instance_or_manager.__class__.__name__
             if class_name != 'ManyRelatedManager':


Test patch:
diff --git a/example/tests/test_views.py b/example/tests/test_views.py
--- a/example/tests/test_views.py
+++ b/example/tests/test_views.py
@@ -11,7 +11,7 @@
 
 from . import TestBase
 from .. import views
-from example.factories import AuthorFactory, EntryFactory
+from example.factories import AuthorFactory, CommentFactory, EntryFactory
 from example.models import Author, Blog, Comment, Entry
 from example.serializers import AuthorBioSerializer, AuthorTypeSerializer, EntrySerializer
 from example.views import AuthorViewSet
@@ -229,6 +229,50 @@ def test_delete_to_many_relationship_with_change(self):
         response = self.client.delete(url, data=request_data)
         assert response.status_code == 200, response.content.decode()
 
+    def test_new_comment_data_patch_to_many_relationship(self):
+        entry = EntryFactory(blog=self.blog, authors=(self.author,))
+        comment = CommentFactory(entry=entry)
+
+        url = '/authors/{}/relationships/comment_set'.format(self.author.id)
+        request_data = {
+            'data': [{'type': format_resource_type('Comment'), 'id': str(comment.id)}, ]
+        }
+        previous_response = {
+            'data': [
+                {'type': 'comments',
+                 'id': str(self.second_comment.id)
+                 }
+            ],
+            'links': {
+                'self': 'http://testserver/authors/{}/relationships/comment_set'.format(
+                    self.author.id
+                )
+            }
+        }
+
+        response = self.client.get(url)
+        assert response.status_code == 200
+        assert response.json() == previous_response
+
+        new_patched_response = {
+            'data': [
+                {'type': 'comments',
+                 'id': str(comment.id)
+                 }
+            ],
+            'links': {
+                'self': 'http://testserver/authors/{}/relationships/comment_set'.format(
+                    self.author.id
+                )
+            }
+        }
+
+        response = self.client.patch(url, data=request_data)
+        assert response.status_code == 200
+        assert response.json() == new_patched_response
+
+        assert Comment.objects.filter(id=self.second_comment.id).exists()
+
 
 class TestRelatedMixin(APITestCase):