Repository: encode/django-rest-framework

Issue:
3.15 not backwards compatible with 3.14 - "View' should either include a `queryset` attribute, or override the `get_queryset()` method."
In 3.14, if a view was not a list view, and it overrode the `get_object` method, the view did not have to specify the `queryset` attribute or override the `get_queryset()` method.

In these cases, the queryset is not needed or used. However, in 3.15 an AssertionError is now thrown in this case. Was this intentional? 

This appears to be related to the following call in the UpdateModelMixin which was recently added: `queryset = self.filter_queryset(self.get_queryset())`

Upgrading to 3.15 introduces a breaking change that could be missed by many, especially as I see no reference to it in the release notes.


Discussion:
We just ran into this too. Also interested to know if it was intentional - I feel like this has got to be a pretty common pattern?

Related PR: https://github.com/encode/django-rest-framework/pull/8043

I worked around it for now by just adding `ModelClass.objects.none()` to affected view classes, e.g.:
```diff
 class MeView(generics.RetrieveUpdateAPIView):
+    queryset = User.objects.none()
     serializer_class = MeSerializer
 
     def get_object(self):
         return self.request.user
```
Same here
What is your `get_object()` method looking like? I started to look at a potential fix (https://github.com/encode/django-rest-framework/pull/9314) but after opening the pull request I noticed that I made a pretty significant assumption, that the `get_object` method isn't doing any prefetches.
> What is your `get_object()` method looking like? I started to look at a potential fix (#9314) but after opening the pull request I noticed that I made a pretty significant assumption, that the `get_object` method isn't doing any prefetches.

It is sometimes as simple as `return request.user` or potentially doing a `get_or_create` or a query on a queryset with many filters/select_related/prefetch_related/etc - there isn't one set pattern I have here. 

I don't totally understand the rationale behind clearing and refetching the related objects, but perhaps it is something that is just documented as the best practice, but does not throw an error if the queryset property or get_queryset method is not defined?

In the end, I already updated all of the relevant code to define the queryset on my views, but the nature of the breaking change is concerning for others using the library.
> In the end, I already updated all of the relevant code to define the queryset on my views, but the nature of the breaking change is concerning for others using the library.

So if I read that correctly, the error message is not really an issue anymore, but your concern is more around the fact that it was a breaking change despite the major digit of DRF not having changed? Would adding this to the release note be enough to close this issue?

While I agree that it didn't really follow [the advertised deprecation policy](https://www.django-rest-framework.org/community/release-notes/#deprecation-policy), in the case of the 3.15 release, it was a long time coming, and having 1.5 years separating it with the previous release was bound to contain unintended breaking changes...

The fix I suggest would work in simple case, but would still require you to override the queryset in case of prefetches, and it's probably OK. In more complex cases, the error message tells exactly what is wrong, and user can easily fix it.
I guess my concern is largely with the fact that it didn't follow the deprecation policy, but also with the change itself. 

Our code base is pretty extensively tested, but we generally do not go out of our way to test the core functionality in the libraries we use. The fact that there was this breaking change when there was, according to the policy, not supposed to be any, now has me concerned about other changes that might break something in our API that are not as obvious. 

Perhaps having more, smaller, releases could help mitigate some of these issues too. As you say, bundling over a year's worth of updates at once makes for a lot of potential places for issues to arise.

When it comes to the specific change, there are many cases where it doesn't necessarily even make sense to specify the queryset, the most obvious one being when the result of `get_object` is the user on the request, but of course there are many others. There may be cases where `get_object` is not even returning a django model object, but maybe a dict or some other type. Forcing the queryset to be defined in some of these cases just seems unintuitive.

I am obviously not as familiar with the overall code in the library, so I won't argue what should or should not be done, but it seems like some of the use cases were potentially overlooked, and while defining the queryset might be the optimal solution, making it required in these cases seems perhaps unncessary. 


> So if I read that correctly, the error message is not really an issue anymore,

For those who happen to have "fixed" their downstream code. It is still a DRF regression.

> but your concern is more around the fact that it was a breaking change despite the major digit of DRF not having changed? Would adding this to the release note be enough to close this issue?

Not at all! A patch release with a fix in DRF is required, or alternatively the release should be withdrawn and re-released with a major version bump.

> While I agree that it didn't really follow [the advertised deprecation policy](https://www.django-rest-framework.org/community/release-notes/#deprecation-policy), in the case of the 3.15 release, it was a long time coming, and having 1.5 years separating it with the previous release was bound to contain unintended breaking changes...

That's a really concerning statement. Mature software with few releases generally is extraordinarily stable, not extraordinarily unstable.

There's another regression caught by our application-side tests. I haven't had time to report it, but am planning to do so later this week.

In my view, this is a symptom of a larger issue, as it suggests that DRF has insufficient test coverage.

> The fix I suggest would work in simple case, but would still require you to override the queryset in case of prefetches, and it's probably OK. In more complex cases, the error message tells exactly what is wrong, and user can easily fix it.

The code should simply gracefully handle (the exception in) scenarios where the queryset isn't defined or not of the expected type.
> > So if I read that correctly, the error message is not really an issue anymore,
> 
> For those who happen to have "fixed" their downstream code. It is still a DRF regression.
> 
> > but your concern is more around the fact that it was a breaking change despite the major digit of DRF not having changed? Would adding this to the release note be enough to close this issue?
> 
> Not at all! A patch release with a fix in DRF is required, or alternatively the release should be withdrawn and re-released with a major version bump.

Definitely agreed here - this is a regression and should be addressed.
> While I agree that it didn't really follow [the advertised deprecation policy](https://www.django-rest-framework.org/community/release-notes/#deprecation-policy), in the case of the 3.15 release, it was a long time coming, and having 1.5 years separating it with the previous release was bound to contain unintended breaking changes...

How can there be unintended breaking changes in a point release? This is extremely concerning.
>> While I agree that it didn't really follow [the advertised deprecation policy](https://www.django-rest-framework.org/community/release-notes/#deprecation-policy), in the case of the 3.15 release, it was a long time coming, and having 1.5 years separating it with the previous release was bound to contain unintended breaking changes...
>
> That's a really concerning statement. Mature software with few releases generally is extraordinarily stable, not extraordinarily unstable.

So... I'm in complete agreement here.

We've got two different aspects to be addressed here...

* Releasing a 3.15.1 version, with appropriate fixes in place.
* Ensuring that we make the project PR policy really clear, and [ensure that REST framework *isn't* accepting further changes other than security related fixes and changes required by newer Django versions](https://github.com/encode/django-rest-framework/discussions/9270#discussioncomment-8828323).
> > > While I agree that it didn't really follow [the advertised deprecation policy](https://www.django-rest-framework.org/community/release-notes/#deprecation-policy), in the case of the 3.15 release, it was a long time coming, and having 1.5 years separating it with the previous release was bound to contain unintended breaking changes...
> > 
> > 
> > That's a really concerning statement. Mature software with few releases generally is extraordinarily stable, not extraordinarily unstable.
> 
> So... I'm in complete agreement here.
> 
> We've got two different aspects to be addressed here...
> 
> * Releasing a 3.15.1 version, with appropriate fixes in place.
> * Ensuring that we make the project PR policy really clear, and [ensure that REST framework _isn't_ accepting further changes other than security related fixes and changes required by newer Django versions](https://github.com/encode/django-rest-framework/discussions/9270#discussioncomment-8828323).

This is good to hear. 

My biggest concern was not that I had to update my code with a new release, its that this was a point release that should not have required me to do so. 

I am most concerned about what other updates were made that may cause issues that are not as immediately obvious, and it seems like there are at least a few others looking at the issue list. 

The whole situation has left me less confident in future DRF releases and considering rolling back to 3.14 as I know that it is stable and have much higher confidence in it behaving as I expect.

Resolving patch:
diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py
--- a/rest_framework/mixins.py
+++ b/rest_framework/mixins.py
@@ -4,8 +4,6 @@
 We don't bind behaviour to http method handlers yet,
 which allows mixin classes to be composed in interesting ways.
 """
-from django.db.models.query import prefetch_related_objects
-
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.settings import api_settings
@@ -69,13 +67,10 @@ def update(self, request, *args, **kwargs):
         serializer.is_valid(raise_exception=True)
         self.perform_update(serializer)
 
-        queryset = self.filter_queryset(self.get_queryset())
-        if queryset._prefetch_related_lookups:
+        if getattr(instance, '_prefetched_objects_cache', None):
             # If 'prefetch_related' has been applied to a queryset, we need to
-            # forcibly invalidate the prefetch cache on the instance,
-            # and then re-prefetch related objects
+            # forcibly invalidate the prefetch cache on the instance.
             instance._prefetched_objects_cache = {}
-            prefetch_related_objects([instance], *queryset._prefetch_related_lookups)
 
         return Response(serializer.data)
 


Test patch:
diff --git a/tests/test_prefetch_related.py b/tests/test_prefetch_related.py
--- a/tests/test_prefetch_related.py
+++ b/tests/test_prefetch_related.py
@@ -1,5 +1,4 @@
 from django.contrib.auth.models import Group, User
-from django.db.models.query import Prefetch
 from django.test import TestCase
 
 from rest_framework import generics, serializers
@@ -9,84 +8,51 @@
 
 
 class UserSerializer(serializers.ModelSerializer):
-    permissions = serializers.SerializerMethodField()
-
-    def get_permissions(self, obj):
-        ret = []
-        for g in obj.groups.all():
-            ret.extend([p.pk for p in g.permissions.all()])
-        return ret
-
     class Meta:
         model = User
-        fields = ('id', 'username', 'email', 'groups', 'permissions')
-
-
-class UserRetrieveUpdate(generics.RetrieveUpdateAPIView):
-    queryset = User.objects.exclude(username='exclude').prefetch_related(
-        Prefetch('groups', queryset=Group.objects.exclude(name='exclude')),
-        'groups__permissions',
-    )
-    serializer_class = UserSerializer
+        fields = ('id', 'username', 'email', 'groups')
 
 
-class UserUpdateWithoutPrefetchRelated(generics.UpdateAPIView):
-    queryset = User.objects.exclude(username='exclude')
+class UserUpdate(generics.UpdateAPIView):
+    queryset = User.objects.exclude(username='exclude').prefetch_related('groups')
     serializer_class = UserSerializer
 
 
 class TestPrefetchRelatedUpdates(TestCase):
     def setUp(self):
         self.user = User.objects.create(username='tom', email='tom@example.com')
-        self.groups = [Group.objects.create(name=f'group {i}') for i in range(10)]
+        self.groups = [Group.objects.create(name='a'), Group.objects.create(name='b')]
         self.user.groups.set(self.groups)
-        self.user.groups.add(Group.objects.create(name='exclude'))
-        self.expected = {
-            'id': self.user.pk,
-            'username': 'tom',
-            'groups': [group.pk for group in self.groups],
-            'email': 'tom@example.com',
-            'permissions': [],
-        }
-        self.view = UserRetrieveUpdate.as_view()
 
     def test_prefetch_related_updates(self):
-        self.groups.append(Group.objects.create(name='c'))
-        request = factory.put(
-            '/', {'username': 'new', 'groups': [group.pk for group in self.groups]}, format='json'
-        )
-        self.expected['username'] = 'new'
-        self.expected['groups'] = [group.pk for group in self.groups]
-        response = self.view(request, pk=self.user.pk)
-        assert User.objects.get(pk=self.user.pk).groups.count() == 12
-        assert response.data == self.expected
-        # Update and fetch should get same result
-        request = factory.get('/')
-        response = self.view(request, pk=self.user.pk)
-        assert response.data == self.expected
+        view = UserUpdate.as_view()
+        pk = self.user.pk
+        groups_pk = self.groups[0].pk
+        request = factory.put('/', {'username': 'new', 'groups': [groups_pk]}, format='json')
+        response = view(request, pk=pk)
+        assert User.objects.get(pk=pk).groups.count() == 1
+        expected = {
+            'id': pk,
+            'username': 'new',
+            'groups': [1],
+            'email': 'tom@example.com'
+        }
+        assert response.data == expected
 
     def test_prefetch_related_excluding_instance_from_original_queryset(self):
         """
         Regression test for https://github.com/encode/django-rest-framework/issues/4661
         """
-        request = factory.put(
-            '/', {'username': 'exclude', 'groups': [self.groups[0].pk]}, format='json'
-        )
-        response = self.view(request, pk=self.user.pk)
-        assert User.objects.get(pk=self.user.pk).groups.count() == 2
-        self.expected['username'] = 'exclude'
-        self.expected['groups'] = [self.groups[0].pk]
-        assert response.data == self.expected
-
-    def test_db_query_count(self):
-        request = factory.put(
-            '/', {'username': 'new'}, format='json'
-        )
-        with self.assertNumQueries(7):
-            self.view(request, pk=self.user.pk)
-
-        request = factory.put(
-            '/', {'username': 'new2'}, format='json'
-        )
-        with self.assertNumQueries(16):
-            UserUpdateWithoutPrefetchRelated.as_view()(request, pk=self.user.pk)
+        view = UserUpdate.as_view()
+        pk = self.user.pk
+        groups_pk = self.groups[0].pk
+        request = factory.put('/', {'username': 'exclude', 'groups': [groups_pk]}, format='json')
+        response = view(request, pk=pk)
+        assert User.objects.get(pk=pk).groups.count() == 1
+        expected = {
+            'id': pk,
+            'username': 'exclude',
+            'groups': [1],
+            'email': 'tom@example.com'
+        }
+        assert response.data == expected