Commit 71ada3a8 authored by Loic Bistuer's avatar Loic Bistuer
Browse files

Fixed #6707 -- Added RelatedManager.set() and made descriptors' __set__ use it.

Thanks Anssi Kääriäinen, Carl Meyer, Collin Anderson, and Tim Graham for the reviews.
parent 49516f71
Loading
Loading
Loading
Loading
+26 −9
Original line number Diff line number Diff line
@@ -436,16 +436,8 @@ class ReverseGenericRelatedObjectsDescriptor(object):
        return manager

    def __set__(self, instance, value):
        # Force evaluation of `value` in case it's a queryset whose
        # value could be affected by `manager.clear()`. Refs #19816.
        value = tuple(value)

        manager = self.__get__(instance)
        db = router.db_for_write(manager.model, instance=manager.instance)
        with transaction.atomic(using=db, savepoint=False):
            manager.clear()
            for obj in value:
                manager.add(obj)
        manager.set(value)


def create_generic_related_manager(superclass):
@@ -561,6 +553,31 @@ def create_generic_related_manager(superclass):
                        obj.delete()
        _clear.alters_data = True

        def set(self, objs, **kwargs):
            clear = kwargs.pop('clear', False)

            db = router.db_for_write(self.model, instance=self.instance)
            with transaction.atomic(using=db, savepoint=False):
                if clear:
                    # Force evaluation of `objs` in case it's a queryset whose value
                    # could be affected by `manager.clear()`. Refs #19816.
                    objs = tuple(objs)

                    self.clear()
                    self.add(*objs)
                else:
                    old_objs = set(self.using(db).all())
                    new_objs = []
                    for obj in objs:
                        if obj in old_objs:
                            old_objs.remove(obj)
                        else:
                            new_objs.append(obj)

                    self.remove(*old_objs)
                    self.add(*new_objs)
        set.alters_data = True

        def create(self, **kwargs):
            kwargs[self.content_type_field_name] = self.content_type
            kwargs[self.object_id_field_name] = self.pk_val
+68 −34
Original line number Diff line number Diff line
@@ -796,6 +796,34 @@ def create_foreign_related_manager(superclass, rel_field, rel_model):
                            obj.save(update_fields=[rel_field.name])
            _clear.alters_data = True

        def set(self, objs, **kwargs):
            clear = kwargs.pop('clear', False)

            if rel_field.null:
                db = router.db_for_write(self.model, instance=self.instance)
                with transaction.atomic(using=db, savepoint=False):
                    if clear:
                        # Force evaluation of `objs` in case it's a queryset whose value
                        # could be affected by `manager.clear()`. Refs #19816.
                        objs = tuple(objs)

                        self.clear()
                        self.add(*objs)
                    else:
                        old_objs = set(self.using(db).all())
                        new_objs = []
                        for obj in objs:
                            if obj in old_objs:
                                old_objs.remove(obj)
                            else:
                                new_objs.append(obj)

                        self.remove(*old_objs)
                        self.add(*new_objs)
            else:
                self.add(*objs)
        set.alters_data = True

    return RelatedManager


@@ -815,18 +843,8 @@ class ForeignRelatedObjectsDescriptor(object):
        return self.related_manager_cls(instance)

    def __set__(self, instance, value):
        # Force evaluation of `value` in case it's a queryset whose
        # value could be affected by `manager.clear()`. Refs #19816.
        value = tuple(value)

        manager = self.__get__(instance)
        db = router.db_for_write(manager.model, instance=manager.instance)
        with transaction.atomic(using=db, savepoint=False):
            # If the foreign key can support nulls, then completely clear the related set.
            # Otherwise, just move the named objects into the set.
            if self.related.field.null:
                manager.clear()
            manager.add(*value)
        manager.set(value)

    @cached_property
    def related_manager_cls(self):
@@ -1002,6 +1020,43 @@ def create_many_related_manager(superclass, rel):
                    model=self.model, pk_set=None, using=db)
        clear.alters_data = True

        def set(self, objs, **kwargs):
            if not rel.through._meta.auto_created:
                opts = self.through._meta
                raise AttributeError(
                    "Cannot set values on a ManyToManyField which specifies an "
                    "intermediary model. Use %s.%s's Manager instead." %
                    (opts.app_label, opts.object_name)
                )

            clear = kwargs.pop('clear', False)

            db = router.db_for_write(self.through, instance=self.instance)
            with transaction.atomic(using=db, savepoint=False):
                if clear:
                    # Force evaluation of `objs` in case it's a queryset whose value
                    # could be affected by `manager.clear()`. Refs #19816.
                    objs = tuple(objs)

                    self.clear()
                    self.add(*objs)
                else:
                    old_ids = set(self.using(db).values_list(self.target_field.related_field.attname, flat=True))

                    new_objs = []
                    for obj in objs:
                        fk_val = (self.target_field.get_foreign_related_value(obj)[0]
                            if isinstance(obj, self.model) else obj)

                        if fk_val in old_ids:
                            old_ids.remove(fk_val)
                        else:
                            new_objs.append(obj)

                    self.remove(*old_ids)
                    self.add(*new_objs)
        set.alters_data = True

        def create(self, **kwargs):
            # This check needs to be done here, since we can't later remove this
            # from the method lookup table, as we do with add and remove.
@@ -1181,22 +1236,8 @@ class ManyRelatedObjectsDescriptor(object):
        return manager

    def __set__(self, instance, value):
        if not self.related.field.rel.through._meta.auto_created:
            opts = self.related.field.rel.through._meta
            raise AttributeError(
                "Cannot set values on a ManyToManyField which specifies an "
                "intermediary model. Use %s.%s's Manager instead." % (opts.app_label, opts.object_name)
            )

        # Force evaluation of `value` in case it's a queryset whose
        # value could be affected by `manager.clear()`. Refs #19816.
        value = tuple(value)

        manager = self.__get__(instance)
        db = router.db_for_write(manager.through, instance=manager.instance)
        with transaction.atomic(using=db, savepoint=False):
            manager.clear()
            manager.add(*value)
        manager.set(value)


class ReverseManyRelatedObjectsDescriptor(object):
@@ -1251,15 +1292,8 @@ class ReverseManyRelatedObjectsDescriptor(object):
                "intermediary model.  Use %s.%s's Manager instead." % (opts.app_label, opts.object_name)
            )

        # Force evaluation of `value` in case it's a queryset whose
        # value could be affected by `manager.clear()`. Refs #19816.
        value = tuple(value)

        manager = self.__get__(instance)
        db = router.db_for_write(manager.through, instance=manager.instance)
        with transaction.atomic(using=db, savepoint=False):
            manager.clear()
            manager.add(*value)
        manager.set(value)


class ForeignObjectRel(object):
+32 −7
Original line number Diff line number Diff line
@@ -135,12 +135,31 @@ Related objects reference
        :class:`~django.db.models.ForeignKey`\s where ``null=True`` and it also
        accepts the ``bulk`` keyword argument.

    .. method:: set(objs, clear=False)

        .. versionadded:: 1.9

        Replace the set of related objects::

            >>> new_list = [obj1, obj2, obj3]
            >>> e.related_set.set(new_list)

        This method accepts a ``clear`` argument to control how to perform the
        operation. If ``False`` (the default), the elements missing from the
        new set are removed using ``remove()`` and only the new ones are added.
        If ``clear=True``, the ``clear()`` method is called instead and the
        whole set is added at once.

        Note that since ``set()`` is a compound operation, it is subject to
        race conditions. For instance, new objects may be added to the database
        in between the call to ``clear()`` and the call to ``add()``.

    .. note::

       Note that ``add()``, ``create()``, ``remove()``, and ``clear()`` all
       apply database changes immediately for all types of related fields. In
       other words, there is no need to call ``save()`` on either end of the
       relationship.
       Note that ``add()``, ``create()``, ``remove()``, ``clear()``, and
       ``set()`` all apply database changes immediately for all types of
       related fields. In other words, there is no need to call ``save()``
       on either end of the relationship.

       Also, if you are using :ref:`an intermediate model
       <intermediary-manytomany>` for a many-to-many relationship, some of the
@@ -158,6 +177,12 @@ new iterable of objects to it::
    >>> e.related_set = new_list

If the foreign key relationship has ``null=True``, then the related manager
will first call ``clear()`` to disassociate any existing objects in the related
set before adding the contents of ``new_list``. Otherwise the objects in
``new_list`` will be added to the existing related object set.
will first disassociate any existing objects in the related set before adding
the contents of ``new_list``. Otherwise the objects in ``new_list`` will be
added to the existing related object set.

.. versionchanged:1.9

    In earlier versions, direct assignment used to perform ``clear()`` followed
    by ``add()``. It now performs a ``set()`` with the keyword argument
    ``clear=False``.
+23 −1
Original line number Diff line number Diff line
@@ -127,7 +127,10 @@ Management Commands
Models
^^^^^^

* ...
* Added the :meth:`RelatedManager.set()
  <django.db.models.fields.related.RelatedManager.set()>` method to the related
  managers created by ``ForeignKey``, ``GenericForeignKey``, and
  ``ManyToManyField``.

Signals
^^^^^^^
@@ -192,6 +195,25 @@ used by the egg loader to detect if setuptools was installed. The ``is_usable``
attribute is now removed and the egg loader instead fails at runtime if
setuptools is not installed.

Related set direct assignment
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

:ref:`Direct assignment <direct-assignment>`) used to perform a ``clear()``
followed by a call to ``add()``. This caused needlessly large data changes
and prevented using the :data:`~django.db.models.signals.m2m_changed` signal
to track individual changes in many-to-many relations.

Direct assignment now relies on the the new
:meth:`django.db.models.fields.related.RelatedManager.set()` method on
related managers which by default only processes changes between the
existing related set and the one that's newly assigned. The previous behavior
can be restored by replacing direct assignment by a call to ``set()`` with
the keyword argument ``clear=True``.

``ModelForm``, and therefore ``ModelAdmin``, internally rely on direct
assignment for many-to-many relations and as a consequence now use the new
behavior.

Miscellaneous
~~~~~~~~~~~~~

+3 −0
Original line number Diff line number Diff line
@@ -1190,6 +1190,9 @@ be found in the :doc:`related objects reference </ref/models/relations>`.
``clear()``
    Removes all objects from the related object set.

``set(objs)``
    Replace the set of related objects.

To assign the members of a related set in one fell swoop, just assign to it
from any iterable object. The iterable can contain object instances, or just
a list of primary key values. For example::
Loading