Fixed #19580 -- Unified behavior of reverse foreign key and many-to-many relations for unsaved instances.

This commit is contained in:
Albert Defler 2022-01-14 12:44:17 +00:00 committed by Mariusz Felisiak
parent b7f263551c
commit 7ba6ebe914
5 changed files with 80 additions and 8 deletions

View File

@ -627,6 +627,15 @@ def create_reverse_many_to_one_manager(superclass, rel):
self.core_filters = {self.field.name: instance} self.core_filters = {self.field.name: instance}
# Even if this relation is not to pk, we require still pk value.
# The wish is that the instance has been already saved to DB,
# although having a pk value isn't a guarantee of that.
if self.instance.pk is None:
raise ValueError(
f"{instance.__class__.__name__!r} instance needs to have a primary "
f"key value before this relationship can be used."
)
def __call__(self, *, manager): def __call__(self, *, manager):
manager = getattr(self.model, manager) manager = getattr(self.model, manager)
manager_class = create_reverse_many_to_one_manager(manager.__class__, rel) manager_class = create_reverse_many_to_one_manager(manager.__class__, rel)
@ -634,6 +643,14 @@ def create_reverse_many_to_one_manager(superclass, rel):
do_not_call_in_templates = True do_not_call_in_templates = True
def _check_fk_val(self):
for field in self.field.foreign_related_fields:
if getattr(self.instance, field.attname) is None:
raise ValueError(
f'"{self.instance!r}" needs to have a value for field '
f'"{field.attname}" before this relationship can be used.'
)
def _apply_rel_filters(self, queryset): def _apply_rel_filters(self, queryset):
""" """
Filter the queryset for the instance this manager is bound to. Filter the queryset for the instance this manager is bound to.
@ -714,6 +731,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
return queryset, rel_obj_attr, instance_attr, False, cache_name, False return queryset, rel_obj_attr, instance_attr, False, cache_name, False
def add(self, *objs, bulk=True): def add(self, *objs, bulk=True):
self._check_fk_val()
self._remove_prefetched_objects() self._remove_prefetched_objects()
db = router.db_for_write(self.model, instance=self.instance) db = router.db_for_write(self.model, instance=self.instance)
@ -752,6 +770,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
add.alters_data = True add.alters_data = True
def create(self, **kwargs): def create(self, **kwargs):
self._check_fk_val()
kwargs[self.field.name] = self.instance kwargs[self.field.name] = self.instance
db = router.db_for_write(self.model, instance=self.instance) db = router.db_for_write(self.model, instance=self.instance)
return super(RelatedManager, self.db_manager(db)).create(**kwargs) return super(RelatedManager, self.db_manager(db)).create(**kwargs)
@ -759,6 +778,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
create.alters_data = True create.alters_data = True
def get_or_create(self, **kwargs): def get_or_create(self, **kwargs):
self._check_fk_val()
kwargs[self.field.name] = self.instance kwargs[self.field.name] = self.instance
db = router.db_for_write(self.model, instance=self.instance) db = router.db_for_write(self.model, instance=self.instance)
return super(RelatedManager, self.db_manager(db)).get_or_create(**kwargs) return super(RelatedManager, self.db_manager(db)).get_or_create(**kwargs)
@ -766,6 +786,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
get_or_create.alters_data = True get_or_create.alters_data = True
def update_or_create(self, **kwargs): def update_or_create(self, **kwargs):
self._check_fk_val()
kwargs[self.field.name] = self.instance kwargs[self.field.name] = self.instance
db = router.db_for_write(self.model, instance=self.instance) db = router.db_for_write(self.model, instance=self.instance)
return super(RelatedManager, self.db_manager(db)).update_or_create(**kwargs) return super(RelatedManager, self.db_manager(db)).update_or_create(**kwargs)
@ -779,6 +800,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
def remove(self, *objs, bulk=True): def remove(self, *objs, bulk=True):
if not objs: if not objs:
return return
self._check_fk_val()
val = self.field.get_foreign_related_value(self.instance) val = self.field.get_foreign_related_value(self.instance)
old_ids = set() old_ids = set()
for obj in objs: for obj in objs:
@ -802,6 +824,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
remove.alters_data = True remove.alters_data = True
def clear(self, *, bulk=True): def clear(self, *, bulk=True):
self._check_fk_val()
self._clear(self, bulk) self._clear(self, bulk)
clear.alters_data = True clear.alters_data = True
@ -822,6 +845,7 @@ def create_reverse_many_to_one_manager(superclass, rel):
_clear.alters_data = True _clear.alters_data = True
def set(self, objs, *, bulk=True, clear=False): def set(self, objs, *, bulk=True, clear=False):
self._check_fk_val()
# Force evaluation of `objs` in case it's a queryset whose value # Force evaluation of `objs` in case it's a queryset whose value
# could be affected by `manager.clear()`. Refs #19816. # could be affected by `manager.clear()`. Refs #19816.
objs = tuple(objs) objs = tuple(objs)

View File

@ -375,6 +375,14 @@ this difference. In Django 4.0 and earlier,
second example query, but this undocumented behavior led to queries with second example query, but this undocumented behavior led to queries with
excessive joins. excessive joins.
Reverse foreign key changes for unsaved model instances
-------------------------------------------------------
In order to unify the behavior with many-to-many relations for unsaved model
instances, a reverse foreign key now raises ``ValueError`` when calling
:class:`related managers <django.db.models.fields.related.RelatedManager>` for
unsaved objects.
Miscellaneous Miscellaneous
------------- -------------

View File

@ -738,14 +738,16 @@ class ManyToOneTests(TestCase):
self.assertEqual("id", cat.remote_field.get_related_field().name) self.assertEqual("id", cat.remote_field.get_related_field().name)
def test_relation_unsaved(self): def test_relation_unsaved(self):
# The <field>_set manager does not join on Null value fields (#17541)
Third.objects.create(name="Third 1") Third.objects.create(name="Third 1")
Third.objects.create(name="Third 2") Third.objects.create(name="Third 2")
th = Third(name="testing") th = Third(name="testing")
# The object isn't saved and thus the relation field is null - we won't even # The object isn't saved and the relation cannot be used.
# execute a query in this case. msg = (
with self.assertNumQueries(0): "'Third' instance needs to have a primary key value before this "
self.assertEqual(th.child_set.count(), 0) "relationship can be used."
)
with self.assertRaisesMessage(ValueError, msg):
th.child_set.count()
th.save() th.save()
# Now the model is saved, so we will need to execute a query. # Now the model is saved, so we will need to execute a query.
with self.assertNumQueries(1): with self.assertNumQueries(1):

View File

@ -146,3 +146,36 @@ class ManyToOneNullTests(TestCase):
self.assertIs(d1.car, None) self.assertIs(d1.car, None)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.assertEqual(list(c1.drivers.all()), []) self.assertEqual(list(c1.drivers.all()), [])
def test_unsaved(self):
msg = (
"'Car' instance needs to have a primary key value before this relationship "
"can be used."
)
with self.assertRaisesMessage(ValueError, msg):
Car(make="Ford").drivers.all()
def test_related_null_to_field_related_managers(self):
car = Car.objects.create(make=None)
driver = Driver.objects.create()
msg = (
f'"{car!r}" needs to have a value for field "make" before this '
f"relationship can be used."
)
with self.assertRaisesMessage(ValueError, msg):
car.drivers.add(driver)
with self.assertRaisesMessage(ValueError, msg):
car.drivers.create()
with self.assertRaisesMessage(ValueError, msg):
car.drivers.get_or_create()
with self.assertRaisesMessage(ValueError, msg):
car.drivers.update_or_create()
with self.assertRaisesMessage(ValueError, msg):
car.drivers.remove(driver)
with self.assertRaisesMessage(ValueError, msg):
car.drivers.clear()
with self.assertRaisesMessage(ValueError, msg):
car.drivers.set([driver])
with self.assertNumQueries(0):
self.assertEqual(car.drivers.count(), 0)

View File

@ -44,9 +44,14 @@ class NullQueriesTests(TestCase):
with self.assertRaisesMessage(ValueError, "Cannot use None as a query value"): with self.assertRaisesMessage(ValueError, "Cannot use None as a query value"):
Choice.objects.filter(id__gt=None) Choice.objects.filter(id__gt=None)
# Related managers use __exact=None implicitly if the object hasn't been saved. def test_unsaved(self):
p2 = Poll(question="How?") poll = Poll(question="How?")
self.assertEqual(repr(p2.choice_set.all()), "<QuerySet []>") msg = (
"'Poll' instance needs to have a primary key value before this "
"relationship can be used."
)
with self.assertRaisesMessage(ValueError, msg):
poll.choice_set.all()
def test_reverse_relations(self): def test_reverse_relations(self):
""" """