Skip to content

Commit

Permalink
Fixed django#9475 -- Allowed RelatedManager.add(), create(), etc. for…
Browse files Browse the repository at this point in the history
… m2m with a through model.
  • Loading branch information
collinanderson authored and timgraham committed Jan 15, 2019
1 parent 1508e71 commit 85d3a30
Show file tree
Hide file tree
Showing 8 changed files with 161 additions and 178 deletions.
69 changes: 22 additions & 47 deletions django/db/models/fields/related_descriptors.py
Expand Up @@ -956,32 +956,23 @@ def count(self):
constrained_target = self.constrained_target
return constrained_target.count() if constrained_target else super().count()

def add(self, *objs):
if not rel.through._meta.auto_created:
opts = self.through._meta
raise AttributeError(
"Cannot use add() on a ManyToManyField which specifies an "
"intermediary model. Use %s.%s's Manager instead." %
(opts.app_label, opts.object_name)
)
def add(self, *objs, through_defaults=None):
self._remove_prefetched_objects()
db = router.db_for_write(self.through, instance=self.instance)
with transaction.atomic(using=db, savepoint=False):
self._add_items(self.source_field_name, self.target_field_name, *objs)

self._add_items(
self.source_field_name, self.target_field_name, *objs,
through_defaults=through_defaults,
)
# If this is a symmetrical m2m relation to self, add the mirror entry in the m2m table
if self.symmetrical:
self._add_items(self.target_field_name, self.source_field_name, *objs)
self._add_items(
self.target_field_name, self.source_field_name, *objs,
through_defaults=through_defaults,
)
add.alters_data = True

def remove(self, *objs):
if not rel.through._meta.auto_created:
opts = self.through._meta
raise AttributeError(
"Cannot use remove() on a ManyToManyField which specifies "
"an intermediary model. Use %s.%s's Manager instead." %
(opts.app_label, opts.object_name)
)
self._remove_prefetched_objects()
self._remove_items(self.source_field_name, self.target_field_name, *objs)
remove.alters_data = True
Expand All @@ -1005,15 +996,7 @@ def clear(self):
)
clear.alters_data = True

def set(self, objs, *, clear=False):
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)
)

def set(self, objs, *, clear=False, through_defaults=None):
# Force evaluation of `objs` in case it's a queryset whose value
# could be affected by `manager.clear()`. Refs #19816.
objs = tuple(objs)
Expand All @@ -1022,7 +1005,7 @@ def set(self, objs, *, clear=False):
with transaction.atomic(using=db, savepoint=False):
if clear:
self.clear()
self.add(*objs)
self.add(*objs, through_defaults=through_defaults)
else:
old_ids = set(self.using(db).values_list(self.target_field.target_field.attname, flat=True))

Expand All @@ -1038,49 +1021,41 @@ def set(self, objs, *, clear=False):
new_objs.append(obj)

self.remove(*old_ids)
self.add(*new_objs)
self.add(*new_objs, through_defaults=through_defaults)
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.
if not self.through._meta.auto_created:
opts = self.through._meta
raise AttributeError(
"Cannot use create() on a ManyToManyField which specifies "
"an intermediary model. Use %s.%s's Manager instead." %
(opts.app_label, opts.object_name)
)
def create(self, through_defaults=None, **kwargs):
db = router.db_for_write(self.instance.__class__, instance=self.instance)
new_obj = super(ManyRelatedManager, self.db_manager(db)).create(**kwargs)
self.add(new_obj)
self.add(new_obj, through_defaults=through_defaults)
return new_obj
create.alters_data = True

def get_or_create(self, **kwargs):
def get_or_create(self, through_defaults=None, **kwargs):
db = router.db_for_write(self.instance.__class__, instance=self.instance)
obj, created = super(ManyRelatedManager, self.db_manager(db)).get_or_create(**kwargs)
# We only need to add() if created because if we got an object back
# from get() then the relationship already exists.
if created:
self.add(obj)
self.add(obj, through_defaults=through_defaults)
return obj, created
get_or_create.alters_data = True

def update_or_create(self, **kwargs):
def update_or_create(self, through_defaults=None, **kwargs):
db = router.db_for_write(self.instance.__class__, instance=self.instance)
obj, created = super(ManyRelatedManager, self.db_manager(db)).update_or_create(**kwargs)
# We only need to add() if created because if we got an object back
# from get() then the relationship already exists.
if created:
self.add(obj)
self.add(obj, through_defaults=through_defaults)
return obj, created
update_or_create.alters_data = True

def _add_items(self, source_field_name, target_field_name, *objs):
def _add_items(self, source_field_name, target_field_name, *objs, through_defaults=None):
# source_field_name: the PK fieldname in join table for the source object
# target_field_name: the PK fieldname in join table for the target object
# *objs - objects to add. Either object instances, or primary keys of object instances.
through_defaults = through_defaults or {}

# If there aren't any objects, there is nothing to do.
from django.db.models import Model
Expand Down Expand Up @@ -1130,10 +1105,10 @@ def _add_items(self, source_field_name, target_field_name, *objs):

# Add the ones that aren't there already
self.through._default_manager.using(db).bulk_create([
self.through(**{
self.through(**dict(through_defaults, **{
'%s_id' % source_field_name: self.related_val[0],
'%s_id' % target_field_name: obj_id,
})
}))
for obj_id in new_ids
])

Expand Down
35 changes: 27 additions & 8 deletions docs/ref/models/relations.txt
Expand Up @@ -36,7 +36,7 @@ Related objects reference
In this example, the methods below will be available both on
``topping.pizza_set`` and on ``pizza.toppings``.

.. method:: add(*objs, bulk=True)
.. method:: add(*objs, bulk=True, through_defaults=None)

Adds the specified model objects to the related object set.

Expand Down Expand Up @@ -66,7 +66,15 @@ Related objects reference
Using ``add()`` on a relation that already exists won't duplicate the
relation, but it will still trigger signals.

.. method:: create(**kwargs)
Use the ``through_defaults`` argument to specify values for the new
:ref:`intermediate model <intermediary-manytomany>` instance, if
needed.

.. versionchanged:: 2.2

The ``through_defaults`` argument was added.

.. method:: create(through_defaults=None, **kwargs)

Creates a new object, saves it and puts it in the related object set.
Returns the newly created object::
Expand Down Expand Up @@ -96,6 +104,14 @@ Related objects reference
parameter ``blog`` to ``create()``. Django figures out that the new
``Entry`` object's ``blog`` field should be set to ``b``.

Use the ``through_defaults`` argument to specify values for the new
:ref:`intermediate model <intermediary-manytomany>` instance, if
needed.

.. versionchanged:: 2.2

The ``through_defaults`` argument was added.

.. method:: remove(*objs, bulk=True)

Removes the specified model objects from the related object set::
Expand Down Expand Up @@ -149,7 +165,7 @@ Related objects reference
For many-to-many relationships, the ``bulk`` keyword argument doesn't
exist.

.. method:: set(objs, bulk=True, clear=False)
.. method:: set(objs, bulk=True, clear=False, through_defaults=None)

Replace the set of related objects::

Expand All @@ -172,18 +188,21 @@ Related objects reference
race conditions. For instance, new objects may be added to the database
in between the call to ``clear()`` and the call to ``add()``.

Use the ``through_defaults`` argument to specify values for the new
:ref:`intermediate model <intermediary-manytomany>` instance, if
needed.

.. versionchanged:: 2.2

The ``through_defaults`` argument was added.

.. note::

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, then the
``add()``, ``create()``, ``remove()``, and ``set()`` methods are
disabled.

If you use :meth:`~django.db.models.query.QuerySet.prefetch_related`,
the ``add()``, ``remove()``, ``clear()``, and ``set()`` methods clear
the prefetched cache.
7 changes: 7 additions & 0 deletions docs/releases/2.2.txt
Expand Up @@ -256,6 +256,13 @@ Models
specified on initialization to ensure that the aggregate function is only
called for each distinct value of ``expressions``.

* The :meth:`.RelatedManager.add`, :meth:`~.RelatedManager.create`,
:meth:`~.RelatedManager.remove`, :meth:`~.RelatedManager.set`,
``get_or_create()``, and ``update_or_create()`` methods are now allowed on
many-to-many relationships with intermediate models. The new
``through_defaults`` argument is used to specify values for new intermediate
model instance.

Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
5 changes: 1 addition & 4 deletions docs/topics/db/examples/many_to_many.txt
Expand Up @@ -34,10 +34,7 @@ objects, and a ``Publication`` has multiple ``Article`` objects:
return self.headline

What follows are examples of operations that can be performed using the Python
API facilities. Note that if you are using :ref:`an intermediate model
<intermediary-manytomany>` for a many-to-many relationship, some of the related
manager's methods are disabled, so some of these examples won't work with such
models.
API facilities.

Create a few ``Publications``::

Expand Down
47 changes: 20 additions & 27 deletions docs/topics/db/models.txt
Expand Up @@ -511,37 +511,31 @@ the intermediate model::
>>> beatles.members.all()
<QuerySet [<Person: Ringo Starr>, <Person: Paul McCartney>]>

Unlike normal many-to-many fields, you *can't* use ``add()``, ``create()``,
or ``set()`` to create relationships::

>>> # The following statements will not work
>>> beatles.members.add(john)
>>> beatles.members.create(name="George Harrison")
>>> beatles.members.set([john, paul, ringo, george])

Why? You can't just create a relationship between a ``Person`` and a ``Group``
- you need to specify all the detail for the relationship required by the
``Membership`` model. The simple ``add``, ``create`` and assignment calls
don't provide a way to specify this extra detail. As a result, they are
disabled for many-to-many relationships that use an intermediate model.
The only way to create this type of relationship is to create instances of the
intermediate model.

The :meth:`~django.db.models.fields.related.RelatedManager.remove` method is
disabled for similar reasons. For example, if the custom through table defined
by the intermediate model does not enforce uniqueness on the
``(model1, model2)`` pair, a ``remove()`` call would not provide enough
information as to which intermediate model instance should be deleted::
You can also use ``add()``, ``create()``, or ``set()`` to create relationships,
as long as your specify ``through_defaults`` for any required fields::

>>> beatles.members.add(john, through_defaults={'date_joined': date(1960, 8, 1)})
>>> beatles.members.create(name="George Harrison", through_defaults={'date_joined': date(1960, 8, 1)})
>>> beatles.members.set([john, paul, ringo, george], through_defaults={'date_joined': date(1960, 8, 1)})

You may prefer to create instances of the intermediate model directly.

If the custom through table defined by the intermediate model does not enforce
uniqueness on the ``(model1, model2)`` pair, allowing multiple values, the
:meth:`~django.db.models.fields.related.RelatedManager.remove` call will
remove all intermediate model instances::

>>> Membership.objects.create(person=ringo, group=beatles,
... date_joined=date(1968, 9, 4),
... invite_reason="You've been gone for a month and we miss you.")
>>> beatles.members.all()
<QuerySet [<Person: Ringo Starr>, <Person: Paul McCartney>, <Person: Ringo Starr>]>
>>> # This will not work because it cannot tell which membership to remove
>>> # This deletes both of the intermediate model instances for Ringo Starr
>>> beatles.members.remove(ringo)
>>> beatles.members.all()
<QuerySet [<Person: Paul McCartney>]>

However, the :meth:`~django.db.models.fields.related.RelatedManager.clear`
The :meth:`~django.db.models.fields.related.RelatedManager.clear`
method can be used to remove all many-to-many relationships for an instance::

>>> # Beatles have broken up
Expand All @@ -550,10 +544,9 @@ method can be used to remove all many-to-many relationships for an instance::
>>> Membership.objects.all()
<QuerySet []>

Once you have established the many-to-many relationships by creating instances
of your intermediate model, you can issue queries. Just as with normal
many-to-many relationships, you can query using the attributes of the
many-to-many-related model::
Once you have established the many-to-many relationships, you can issue
queries. Just as with normal many-to-many relationships, you can query using
the attributes of the many-to-many-related model::

# Find all the groups with a member whose name starts with 'Paul'
>>> Group.objects.filter(members__name__startswith='Paul')
Expand Down
2 changes: 1 addition & 1 deletion tests/m2m_through/models.py
Expand Up @@ -66,7 +66,7 @@ def __str__(self):
class TestNoDefaultsOrNulls(models.Model):
person = models.ForeignKey(Person, models.CASCADE)
group = models.ForeignKey(Group, models.CASCADE)
nodefaultnonull = models.CharField(max_length=5)
nodefaultnonull = models.IntegerField()


class PersonSelfRefM2M(models.Model):
Expand Down

0 comments on commit 85d3a30

Please sign in to comment.