diff --git a/django/core/serializers/base.py b/django/core/serializers/base.py index cc9caf9a82..12bd497ac6 100644 --- a/django/core/serializers/base.py +++ b/django/core/serializers/base.py @@ -290,11 +290,11 @@ class DeserializedObject: def save(self, save_m2m=True, using=None, **kwargs): # Call save on the Model baseclass directly. This bypasses any # model-defined save. The save is also forced to be raw. - # raw=True is passed to any pre/post_save signals. + # raw=True is passed to any pre/post_save and m2m_changed signals. models.Model.save_base(self.object, using=using, raw=True, **kwargs) if self.m2m_data and save_m2m: for accessor_name, object_list in self.m2m_data.items(): - getattr(self.object, accessor_name).set(object_list) + getattr(self.object, accessor_name).set_base(object_list, raw=True) # prevent a second (possibly accidental) call to save() from saving # the m2m data twice. diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py index 622c49837f..40ad8b260f 100644 --- a/django/db/models/fields/related_descriptors.py +++ b/django/db/models/fields/related_descriptors.py @@ -1262,7 +1262,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): else: return super().count() - def _add_base(self, *objs, through_defaults=None, using=None): + def _add_base(self, *objs, through_defaults=None, using=None, raw=False): db = using or router.db_for_write(self.through, instance=self.instance) with transaction.atomic(using=db, savepoint=False): self._add_items( @@ -1271,6 +1271,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): *objs, through_defaults=through_defaults, using=db, + raw=raw, ) # If this is a symmetrical m2m relation to self, add the mirror # entry in the m2m table. @@ -1281,6 +1282,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): *objs, through_defaults=through_defaults, using=db, + raw=raw, ) def add(self, *objs, through_defaults=None): @@ -1297,10 +1299,10 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): aadd.alters_data = True - def _remove_base(self, *objs, using=None): + def _remove_base(self, *objs, using=None, raw=False): db = using or router.db_for_write(self.through, instance=self.instance) self._remove_items( - self.source_field_name, self.target_field_name, *objs, using=db + self.source_field_name, self.target_field_name, *objs, using=db, raw=raw ) def remove(self, *objs): @@ -1315,7 +1317,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): aremove.alters_data = True - def _clear_base(self, using=None): + def _clear_base(self, using=None, raw=False): db = using or router.db_for_write(self.through, instance=self.instance) with transaction.atomic(using=db, savepoint=False): signals.m2m_changed.send( @@ -1326,6 +1328,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): model=self.model, pk_set=None, using=db, + raw=raw, ) filters = self._build_remove_filters(super().get_queryset().using(db)) self.through._default_manager.using(db).filter(filters).delete() @@ -1338,6 +1341,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): model=self.model, pk_set=None, using=db, + raw=raw, ) def clear(self): @@ -1352,7 +1356,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): aclear.alters_data = True - def set_base(self, objs, *, clear=False, through_defaults=None): + def set_base(self, objs, *, clear=False, through_defaults=None, raw=False): # Force evaluation of `objs` in case it's a queryset whose value # could be affected by `manager.clear()`. Refs #19816. objs = tuple(objs) @@ -1361,8 +1365,10 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): with transaction.atomic(using=db, savepoint=False): self._remove_prefetched_objects() if clear: - self._clear_base(using=db) - self._add_base(*objs, through_defaults=through_defaults, using=db) + self._clear_base(using=db, raw=raw) + self._add_base( + *objs, through_defaults=through_defaults, using=db, raw=raw + ) else: old_ids = set( self.using(db).values_list( @@ -1382,9 +1388,9 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): else: new_objs.append(obj) - self._remove_base(*old_ids, using=db) + self._remove_base(*old_ids, using=db, raw=raw) self._add_base( - *new_objs, through_defaults=through_defaults, using=db + *new_objs, through_defaults=through_defaults, using=db, raw=raw ) def set(self, objs, *, clear=False, through_defaults=None): @@ -1545,6 +1551,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): *objs, through_defaults=None, using=None, + raw=False, ): # source_field_name: the PK fieldname in join table for the source # object target_field_name: the PK fieldname in join table for the @@ -1587,6 +1594,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): model=self.model, pk_set=missing_target_ids, using=db, + raw=raw, ) # Add the ones that aren't there already. self.through._default_manager.using(db).bulk_create( @@ -1612,10 +1620,11 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): model=self.model, pk_set=missing_target_ids, using=db, + raw=raw, ) def _remove_items( - self, source_field_name, target_field_name, *objs, using=None + self, source_field_name, target_field_name, *objs, using=None, raw=False ): # source_field_name: the PK colname in join table for the source # object target_field_name: the PK colname in join table for the @@ -1644,6 +1653,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): model=self.model, pk_set=old_ids, using=db, + raw=raw, ) target_model_qs = super().get_queryset() if target_model_qs._has_filters(): @@ -1663,6 +1673,7 @@ def create_forward_many_to_many_manager(superclass, rel, reverse): model=self.model, pk_set=old_ids, using=db, + raw=raw, ) return ManyRelatedManager diff --git a/docs/ref/signals.txt b/docs/ref/signals.txt index 44958dcef3..aecd7f9a44 100644 --- a/docs/ref/signals.txt +++ b/docs/ref/signals.txt @@ -303,6 +303,15 @@ Arguments sent with this signal: ``using`` The database alias being used. +``raw`` + + .. versionadded:: 6.1 + + A boolean; ``True`` if the model is saved exactly as presented + (i.e. when loading a :ref:`fixture `). One should not + query/modify other records in the database as the database might not be in + a consistent state yet. + For example, if a ``Pizza`` can have multiple ``Topping`` objects, modeled like this:: @@ -357,6 +366,8 @@ Argument Value ``pk_set`` ``{t.id}`` (since only ``Topping t`` was added to the relation) ``using`` ``"default"`` (since the default router sends writes here) + +``raw`` ``False`` (since it is not loaded from a fixture) ============== ============================================================ And if we would then do something like this: @@ -387,6 +398,8 @@ Argument Value relation) ``using`` ``"default"`` (since the default router sends writes here) + +``raw`` ``False`` (since it is not loaded from a fixture) ============== ============================================================ ``class_prepared`` diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index 4036514984..b72d6f3f0a 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -237,6 +237,10 @@ Management Commands ``suggest_on_error`` argument to ``True`` by default on Python 3.14, enabling suggestions for mistyped subcommand names and argument choices. +* The :djadmin:`loaddata` command now calls + :data:`~django.db.models.signals.m2m_changed` signals with ``raw=True`` when + loading fixtures. + Migrations ~~~~~~~~~~ diff --git a/docs/topics/db/fixtures.txt b/docs/topics/db/fixtures.txt index 6066d34f8e..9e91e0d845 100644 --- a/docs/topics/db/fixtures.txt +++ b/docs/topics/db/fixtures.txt @@ -118,8 +118,9 @@ How fixtures are saved to the database When fixture files are processed, the data is saved to the database as is. Model defined :meth:`~django.db.models.Model.save` methods are not called, and -any :data:`~django.db.models.signals.pre_save` or -:data:`~django.db.models.signals.post_save` signals will be called with +any :data:`~django.db.models.signals.pre_save`, +:data:`~django.db.models.signals.post_save`, or +:data:`~django.db.models.signals.m2m_changed` signals will be called with ``raw=True`` since the instance only contains attributes that are local to the model. You may, for example, want to disable handlers that access related fields that aren't present during fixture loading and would otherwise @@ -163,6 +164,10 @@ You could also write a decorator to encapsulate this logic:: Just be aware that this logic will disable the signals whenever fixtures are deserialized, not just during :djadmin:`loaddata`. +.. versionchanged:: 6.1 + + The ``raw`` argument was added to ``m2m_changed`` signals. + Compressed fixtures =================== diff --git a/tests/m2m_signals/tests.py b/tests/m2m_signals/tests.py index 488c0a6fbe..980771d54b 100644 --- a/tests/m2m_signals/tests.py +++ b/tests/m2m_signals/tests.py @@ -35,6 +35,7 @@ class ManyToManySignalsTest(TestCase): "action": kwargs["action"], "reverse": kwargs["reverse"], "model": kwargs["model"], + "raw": kwargs["raw"], } if kwargs["pk_set"]: message["objects"] = list( @@ -114,6 +115,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -123,6 +125,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -136,6 +139,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -145,6 +149,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -163,6 +168,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.engine], }, { @@ -170,6 +176,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.engine], }, ], @@ -188,6 +195,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.sunroof], } ) @@ -197,6 +205,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.airbag, self.sunroof], } ) @@ -210,6 +219,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -219,6 +229,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": True, "model": Car, + "raw": False, "objects": [self.bmw, self.toyota], } ) @@ -237,6 +248,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_remove", "reverse": True, "model": Car, + "raw": False, "objects": [self.vw], }, { @@ -244,6 +256,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_remove", "reverse": True, "model": Car, + "raw": False, "objects": [self.vw], }, ], @@ -261,12 +274,14 @@ class ManyToManySignalsTest(TestCase): "action": "pre_clear", "reverse": False, "model": Part, + "raw": False, }, { "instance": self.vw, "action": "post_clear", "reverse": False, "model": Part, + "raw": False, }, ], ) @@ -283,12 +298,14 @@ class ManyToManySignalsTest(TestCase): "action": "pre_clear", "reverse": True, "model": Car, + "raw": False, }, { "instance": self.doors, "action": "post_clear", "reverse": True, "model": Car, + "raw": False, }, ], ) @@ -306,12 +323,14 @@ class ManyToManySignalsTest(TestCase): "action": "pre_clear", "reverse": True, "model": Car, + "raw": False, }, { "instance": self.airbag, "action": "post_clear", "reverse": True, "model": Car, + "raw": False, }, ], ) @@ -330,6 +349,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -339,6 +359,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -352,6 +373,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_remove", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -361,6 +383,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_remove", "reverse": False, "model": Part, + "raw": False, "objects": [p6], } ) @@ -370,6 +393,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -379,6 +403,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -397,6 +422,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_clear", "reverse": False, "model": Part, + "raw": False, } ) expected_messages.append( @@ -405,6 +431,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_clear", "reverse": False, "model": Part, + "raw": False, } ) expected_messages.append( @@ -413,6 +440,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -422,6 +450,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors, self.engine, self.wheelset], } ) @@ -435,6 +464,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.engine], } ) @@ -444,6 +474,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_remove", "reverse": False, "model": Part, + "raw": False, "objects": [self.engine], } ) @@ -464,6 +495,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors], } ) @@ -473,6 +505,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Part, + "raw": False, "objects": [self.doors], } ) @@ -485,6 +518,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": True, "model": Car, + "raw": False, "objects": [c4b], } ) @@ -494,6 +528,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": True, "model": Car, + "raw": False, "objects": [c4b], } ) @@ -519,6 +554,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.bob, self.chuck], }, { @@ -526,6 +562,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.bob, self.chuck], }, ], @@ -542,6 +579,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.daisy], }, { @@ -549,6 +587,7 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": False, "model": Person, + "raw": False, "objects": [self.daisy], }, ], @@ -565,6 +604,7 @@ class ManyToManySignalsTest(TestCase): "action": "pre_add", "reverse": True, "model": Person, + "raw": False, "objects": [self.alice, self.bob], }, { @@ -572,6 +612,89 @@ class ManyToManySignalsTest(TestCase): "action": "post_add", "reverse": True, "model": Person, + "raw": False, + "objects": [self.alice, self.bob], + }, + ], + ) + + def test_m2m_relations_set_base_raw(self): + self.chuck.idols.add(self.daisy) + self._initialize_signal_person() + self.chuck.idols.set_base([self.alice, self.bob], raw=True) + self.assertEqual( + self.m2m_changed_messages, + [ + { + "instance": self.chuck, + "action": "pre_remove", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.daisy], + }, + { + "instance": self.chuck, + "action": "post_remove", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.daisy], + }, + { + "instance": self.chuck, + "action": "pre_add", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.alice, self.bob], + }, + { + "instance": self.chuck, + "action": "post_add", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.alice, self.bob], + }, + ], + ) + + def test_m2m_relations_set_base_raw_clear(self): + self.chuck.idols.set([self.daisy, self.bob]) + self._initialize_signal_person() + self.chuck.idols.set_base([self.alice, self.bob], clear=True, raw=True) + self.assertEqual( + self.m2m_changed_messages, + [ + { + "instance": self.chuck, + "action": "pre_clear", + "reverse": True, + "model": Person, + "raw": True, + }, + { + "instance": self.chuck, + "action": "post_clear", + "reverse": True, + "model": Person, + "raw": True, + }, + { + "instance": self.chuck, + "action": "pre_add", + "reverse": True, + "model": Person, + "raw": True, + "objects": [self.alice, self.bob], + }, + { + "instance": self.chuck, + "action": "post_add", + "reverse": True, + "model": Person, + "raw": True, "objects": [self.alice, self.bob], }, ], diff --git a/tests/serializers/test_data.py b/tests/serializers/test_data.py index c626f2550a..121404a0ef 100644 --- a/tests/serializers/test_data.py +++ b/tests/serializers/test_data.py @@ -112,7 +112,7 @@ def fk_create(pk, klass, data): def m2m_create(pk, klass, data): instance = klass(id=pk) models.Model.save_base(instance, raw=True) - instance.data.set(data) + instance.data.set_base(data, raw=True) return [instance]