diff --git a/django/db/models/fields/related_lookups.py b/django/db/models/fields/related_lookups.py index 99eadc5da7..17a8622ff9 100644 --- a/django/db/models/fields/related_lookups.py +++ b/django/db/models/fields/related_lookups.py @@ -1,3 +1,5 @@ +import warnings + from django.db.models.lookups import ( Exact, GreaterThan, @@ -7,6 +9,7 @@ from django.db.models.lookups import ( LessThan, LessThanOrEqual, ) +from django.utils.deprecation import RemovedInDjango50Warning class MultiColSource: @@ -40,6 +43,15 @@ def get_normalized_value(value, lhs): from django.db.models import Model if isinstance(value, Model): + if value.pk is None: + # When the deprecation ends, replace with: + # raise ValueError( + # "Model instances passed to related filters must be saved." + # ) + warnings.warn( + "Passing unsaved model instances to related filters is deprecated.", + RemovedInDjango50Warning, + ) value_list = [] sources = lhs.output_field.path_infos[-1].target_fields for source in sources: diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index 4818846bea..11f69b1844 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -85,6 +85,8 @@ details on these changes. objects without providing the ``chunk_size`` argument will no longer be allowed. +* Passing unsaved model instances to related filters will no longer be allowed. + .. _deprecation-removed-in-4.1: 4.1 diff --git a/docs/releases/4.1.txt b/docs/releases/4.1.txt index e3112ad0f5..dafee5271f 100644 --- a/docs/releases/4.1.txt +++ b/docs/releases/4.1.txt @@ -484,6 +484,9 @@ Miscellaneous versions, no prefetching was done. Providing a value for ``chunk_size`` signifies that the additional query per chunk needed to prefetch is desired. +* Passing unsaved model instances to related filters is deprecated. In Django + 5.0, the exception will be raised. + Features removed in 4.1 ======================= diff --git a/tests/queries/tests.py b/tests/queries/tests.py index 594afe25b6..800e71557b 100644 --- a/tests/queries/tests.py +++ b/tests/queries/tests.py @@ -12,7 +12,8 @@ from django.db.models.expressions import RawSQL from django.db.models.sql.constants import LOUTER from django.db.models.sql.where import NothingNode, WhereNode from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature -from django.test.utils import CaptureQueriesContext +from django.test.utils import CaptureQueriesContext, ignore_warnings +from django.utils.deprecation import RemovedInDjango50Warning from .models import ( FK1, @@ -1899,6 +1900,19 @@ class Queries5Tests(TestCase): self.assertEqual(Ranking.objects.filter(author__in=authors).get(), self.rank3) self.assertEqual(authors.count(), 1) + def test_filter_unsaved_object(self): + # These tests will catch ValueError in Django 5.0 when passing unsaved + # model instances to related filters becomes forbidden. + # msg = "Model instances passed to related filters must be saved." + msg = "Passing unsaved model instances to related filters is deprecated." + company = Company.objects.create(name="Django") + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + Employment.objects.filter(employer=Company(name="unsaved")) + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + Employment.objects.filter(employer__in=[company, Company(name="unsaved")]) + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + StaffUser.objects.filter(staff=Staff(name="unsaved")) + class SelectRelatedTests(TestCase): def test_tickets_3045_3288(self): @@ -3211,6 +3225,7 @@ class ExcludeTests(TestCase): [self.j1, self.j2], ) + @ignore_warnings(category=RemovedInDjango50Warning) def test_exclude_unsaved_o2o_object(self): jack = Staff.objects.create(name="jack") jack_staff = StaffUser.objects.create(staff=jack) @@ -3221,6 +3236,19 @@ class ExcludeTests(TestCase): StaffUser.objects.exclude(staff=unsaved_object), [jack_staff] ) + def test_exclude_unsaved_object(self): + # These tests will catch ValueError in Django 5.0 when passing unsaved + # model instances to related filters becomes forbidden. + # msg = "Model instances passed to related filters must be saved." + company = Company.objects.create(name="Django") + msg = "Passing unsaved model instances to related filters is deprecated." + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + Employment.objects.exclude(employer=Company(name="unsaved")) + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + Employment.objects.exclude(employer__in=[company, Company(name="unsaved")]) + with self.assertWarnsMessage(RemovedInDjango50Warning, msg): + StaffUser.objects.exclude(staff=Staff(name="unsaved")) + class ExcludeTest17600(TestCase): """