Fixed #27380 -- Added "raw" argument to m2m_changed signals.

This commit is contained in:
Mariusz Felisiak 2025-11-07 10:17:22 +01:00
parent 5e6ba7368c
commit 47f8206609
7 changed files with 166 additions and 16 deletions

View file

@ -262,11 +262,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.

View file

@ -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

View file

@ -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 <fixtures-explanation>`). 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::

View file

@ -225,7 +225,9 @@ Logging
Management Commands
~~~~~~~~~~~~~~~~~~~
* ...
* The :djadmin:`loaddata` command now calls
:data:`~django.db.models.signals.m2m_changed` signals with ``raw=True`` when
loading fixtures.
Migrations
~~~~~~~~~~

View file

@ -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
===================

View file

@ -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],
},
],

View file

@ -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]