Fixed #30421 -- Allowed symmetrical intermediate table for self-referential ManyToManyField.

This commit is contained in:
Nadège Michel 2019-04-19 18:12:04 +02:00 committed by Mariusz Felisiak
parent a9179ab032
commit 87b1ad6e73
11 changed files with 167 additions and 90 deletions

View File

@ -632,6 +632,7 @@ answer newbie questions, and generally made Django that much better:
msundstr msundstr
Mushtaq Ali <mushtaak@gmail.com> Mushtaq Ali <mushtaak@gmail.com>
Mykola Zamkovoi <nickzam@gmail.com> Mykola Zamkovoi <nickzam@gmail.com>
Nadège Michel <michel.nadege@gmail.com>
Nagy Károly <charlie@rendszergazda.com> Nagy Károly <charlie@rendszergazda.com>
Nasimul Haque <nasim.haque@gmail.com> Nasimul Haque <nasim.haque@gmail.com>
Nasir Hussain <nasirhjafri@gmail.com> Nasir Hussain <nasirhjafri@gmail.com>

View File

@ -1234,18 +1234,6 @@ class ManyToManyField(RelatedField):
to_model_name = to_model._meta.object_name to_model_name = to_model._meta.object_name
relationship_model_name = self.remote_field.through._meta.object_name relationship_model_name = self.remote_field.through._meta.object_name
self_referential = from_model == to_model self_referential = from_model == to_model
# Check symmetrical attribute.
if (self_referential and self.remote_field.symmetrical and
not self.remote_field.through._meta.auto_created):
errors.append(
checks.Error(
'Many-to-many fields with intermediate tables must not be symmetrical.',
obj=self,
id='fields.E332',
)
)
# Count foreign keys in intermediate model # Count foreign keys in intermediate model
if self_referential: if self_referential:
seen_self = sum( seen_self = sum(

View File

@ -938,11 +938,14 @@ def create_forward_many_to_many_manager(superclass, rel, reverse):
through_defaults=through_defaults, through_defaults=through_defaults,
) )
# If this is a symmetrical m2m relation to self, add the mirror # If this is a symmetrical m2m relation to self, add the mirror
# entry in the m2m table. `through_defaults` aren't used here # entry in the m2m table.
# because of the system check error fields.E332: Many-to-many
# fields with intermediate tables must not be symmetrical.
if self.symmetrical: 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 add.alters_data = True
def remove(self, *objs): def remove(self, *objs):

View File

@ -222,7 +222,7 @@ Related fields
* **fields.E331**: Field specifies a many-to-many relation through model * **fields.E331**: Field specifies a many-to-many relation through model
``<model>``, which has not been installed. ``<model>``, which has not been installed.
* **fields.E332**: Many-to-many fields with intermediate tables must not be * **fields.E332**: Many-to-many fields with intermediate tables must not be
symmetrical. symmetrical. *This check appeared before Django 3.0.*
* **fields.E333**: The model is used as an intermediate model by ``<model>``, * **fields.E333**: The model is used as an intermediate model by ``<model>``,
but it has more than two foreign keys to ``<model>``, which is ambiguous. but it has more than two foreign keys to ``<model>``, which is ambiguous.
You must specify which two foreign keys Django should use via the You must specify which two foreign keys Django should use via the

View File

@ -1537,6 +1537,11 @@ that control how the relationship functions.
add the descriptor for the reverse relationship, allowing add the descriptor for the reverse relationship, allowing
:class:`ManyToManyField` relationships to be non-symmetrical. :class:`ManyToManyField` relationships to be non-symmetrical.
.. versionchanged:: 3.0
Specifying ``symmetrical=True`` for recursive many-to-many
relationships using an intermediary model was allowed.
.. attribute:: ManyToManyField.through .. attribute:: ManyToManyField.through
Django will automatically generate a table to manage many-to-many Django will automatically generate a table to manage many-to-many
@ -1549,6 +1554,16 @@ that control how the relationship functions.
:ref:`extra data with a many-to-many relationship :ref:`extra data with a many-to-many relationship
<intermediary-manytomany>`. <intermediary-manytomany>`.
.. note::
Recursive relationships using an intermediary model and defined as
symmetrical (that is, with :attr:`symmetrical=True
<ManyToManyField.symmetrical>`, which is the default) can't determine
the reverse accessors names, as they would be the same. You need to set
a :attr:`~ForeignKey.related_name` to at least one of them. If you'd
prefer Django not to create a backwards relation, set ``related_name``
to ``'+'``.
If you don't specify an explicit ``through`` model, there is still an If you don't specify an explicit ``through`` model, there is still an
implicit ``through`` model class you can use to directly access the table implicit ``through`` model class you can use to directly access the table
created to hold the association. It has three fields to link the models. created to hold the association. It has three fields to link the models.
@ -1624,12 +1639,6 @@ that control how the relationship functions.
foreign keys to the model, or you want to explicitly specify which two foreign keys to the model, or you want to explicitly specify which two
Django should use. Django should use.
Recursive relationships using an intermediary model are always defined as
non-symmetrical -- that is, with :attr:`symmetrical=False <ManyToManyField.symmetrical>`
-- therefore, there is the concept of a "source" and a "target". In that
case ``'field1'`` will be treated as the "source" of the relationship and
``'field2'`` as the "target".
.. attribute:: ManyToManyField.db_table .. attribute:: ManyToManyField.db_table
The name of the table to create for storing the many-to-many data. If this The name of the table to create for storing the many-to-many data. If this

View File

@ -254,6 +254,9 @@ Models
* :class:`~django.db.models.FilePathField` now accepts a callable ``path``. * :class:`~django.db.models.FilePathField` now accepts a callable ``path``.
* Allowed symmetrical intermediate table for self-referential
:class:`~django.db.models.ManyToManyField`.
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~

View File

@ -260,24 +260,6 @@ class RelativeFieldTests(SimpleTestCase):
field = Group._meta.get_field('members') field = Group._meta.get_field('members')
self.assertEqual(field.check(from_model=Group), []) self.assertEqual(field.check(from_model=Group), [])
def test_symmetrical_self_referential_field(self):
class Person(models.Model):
# Implicit symmetrical=False.
friends = models.ManyToManyField('self', through="Relationship")
class Relationship(models.Model):
first = models.ForeignKey(Person, models.CASCADE, related_name="rel_from_set")
second = models.ForeignKey(Person, models.CASCADE, related_name="rel_to_set")
field = Person._meta.get_field('friends')
self.assertEqual(field.check(from_model=Person), [
Error(
'Many-to-many fields with intermediate tables must not be symmetrical.',
obj=field,
id='fields.E332',
),
])
def test_too_many_foreign_keys_in_self_referential_model(self): def test_too_many_foreign_keys_in_self_referential_model(self):
class Person(models.Model): class Person(models.Model):
friends = models.ManyToManyField('self', through="InvalidRelationship", symmetrical=False) friends = models.ManyToManyField('self', through="InvalidRelationship", symmetrical=False)
@ -301,52 +283,6 @@ class RelativeFieldTests(SimpleTestCase):
), ),
]) ])
def test_symmetric_self_reference_with_intermediate_table(self):
class Person(models.Model):
# Explicit symmetrical=True.
friends = models.ManyToManyField('self', through="Relationship", symmetrical=True)
class Relationship(models.Model):
first = models.ForeignKey(Person, models.CASCADE, related_name="rel_from_set")
second = models.ForeignKey(Person, models.CASCADE, related_name="rel_to_set")
field = Person._meta.get_field('friends')
self.assertEqual(field.check(from_model=Person), [
Error(
'Many-to-many fields with intermediate tables must not be symmetrical.',
obj=field,
id='fields.E332',
),
])
def test_symmetric_self_reference_with_intermediate_table_and_through_fields(self):
"""
Using through_fields in a m2m with an intermediate model shouldn't
mask its incompatibility with symmetry.
"""
class Person(models.Model):
# Explicit symmetrical=True.
friends = models.ManyToManyField(
'self',
symmetrical=True,
through="Relationship",
through_fields=('first', 'second'),
)
class Relationship(models.Model):
first = models.ForeignKey(Person, models.CASCADE, related_name="rel_from_set")
second = models.ForeignKey(Person, models.CASCADE, related_name="rel_to_set")
referee = models.ForeignKey(Person, models.CASCADE, related_name="referred")
field = Person._meta.get_field('friends')
self.assertEqual(field.check(from_model=Person), [
Error(
'Many-to-many fields with intermediate tables must not be symmetrical.',
obj=field,
id='fields.E332',
),
])
def test_foreign_key_to_abstract_model(self): def test_foreign_key_to_abstract_model(self):
class AbstractModel(models.Model): class AbstractModel(models.Model):
class Meta: class Meta:

View File

@ -22,7 +22,14 @@ from django.db import models
class Person(models.Model): class Person(models.Model):
name = models.CharField(max_length=20) name = models.CharField(max_length=20)
friends = models.ManyToManyField('self') friends = models.ManyToManyField('self')
colleagues = models.ManyToManyField('self', symmetrical=True, through='Colleague')
idols = models.ManyToManyField('self', symmetrical=False, related_name='stalkers') idols = models.ManyToManyField('self', symmetrical=False, related_name='stalkers')
def __str__(self): def __str__(self):
return self.name return self.name
class Colleague(models.Model):
first = models.ForeignKey(Person, models.CASCADE)
second = models.ForeignKey(Person, models.CASCADE, related_name='+')
first_meet = models.DateField()

View File

@ -1,3 +1,5 @@
import datetime
from django.test import TestCase from django.test import TestCase
from .models import Person from .models import Person
@ -59,3 +61,59 @@ class RecursiveM2MTests(TestCase):
self.a.idols.add(self.a) self.a.idols.add(self.a)
self.assertSequenceEqual(self.a.idols.all(), [self.a]) self.assertSequenceEqual(self.a.idols.all(), [self.a])
self.assertSequenceEqual(self.a.stalkers.all(), [self.a]) self.assertSequenceEqual(self.a.stalkers.all(), [self.a])
class RecursiveSymmetricalM2MThroughTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.a, cls.b, cls.c, cls.d = [
Person.objects.create(name=name)
for name in ['Anne', 'Bill', 'Chuck', 'David']
]
cls.a.colleagues.add(cls.b, cls.c, through_defaults={
'first_meet': datetime.date(2013, 1, 5),
})
# Add m2m for Anne and Chuck in reverse direction.
cls.d.colleagues.add(cls.a, cls.c, through_defaults={
'first_meet': datetime.date(2015, 6, 15),
})
def test_recursive_m2m_all(self):
for person, colleagues in (
(self.a, [self.b, self.c, self.d]),
(self.b, [self.a]),
(self.c, [self.a, self.d]),
(self.d, [self.a, self.c]),
):
with self.subTest(person=person):
self.assertSequenceEqual(person.colleagues.all(), colleagues)
def test_recursive_m2m_reverse_add(self):
# Add m2m for Anne in reverse direction.
self.b.colleagues.add(self.a, through_defaults={
'first_meet': datetime.date(2013, 1, 5),
})
self.assertSequenceEqual(self.a.colleagues.all(), [self.b, self.c, self.d])
self.assertSequenceEqual(self.b.colleagues.all(), [self.a])
def test_recursive_m2m_remove(self):
self.b.colleagues.remove(self.a)
self.assertSequenceEqual(self.a.colleagues.all(), [self.c, self.d])
self.assertSequenceEqual(self.b.colleagues.all(), [])
def test_recursive_m2m_clear(self):
# Clear m2m for Anne.
self.a.colleagues.clear()
self.assertSequenceEqual(self.a.friends.all(), [])
# Reverse m2m relationships is removed.
self.assertSequenceEqual(self.c.colleagues.all(), [self.d])
self.assertSequenceEqual(self.d.colleagues.all(), [self.c])
def test_recursive_m2m_set(self):
# Set new relationships for Chuck.
self.c.colleagues.set([self.b, self.d], through_defaults={
'first_meet': datetime.date(2013, 1, 5),
})
self.assertSequenceEqual(self.c.colleagues.order_by('name'), [self.b, self.d])
# Reverse m2m relationships is removed.
self.assertSequenceEqual(self.a.colleagues.order_by('name'), [self.b, self.d])

View File

@ -72,6 +72,7 @@ class TestNoDefaultsOrNulls(models.Model):
class PersonSelfRefM2M(models.Model): class PersonSelfRefM2M(models.Model):
name = models.CharField(max_length=5) name = models.CharField(max_length=5)
friends = models.ManyToManyField('self', through="Friendship", symmetrical=False) friends = models.ManyToManyField('self', through="Friendship", symmetrical=False)
sym_friends = models.ManyToManyField('self', through='SymmetricalFriendship', symmetrical=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -83,6 +84,12 @@ class Friendship(models.Model):
date_friended = models.DateTimeField() date_friended = models.DateTimeField()
class SymmetricalFriendship(models.Model):
first = models.ForeignKey(PersonSelfRefM2M, models.CASCADE)
second = models.ForeignKey(PersonSelfRefM2M, models.CASCADE, related_name='+')
date_friended = models.DateField()
# Custom through link fields # Custom through link fields
class Event(models.Model): class Event(models.Model):
title = models.CharField(max_length=50) title = models.CharField(max_length=50)

View File

@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime
from operator import attrgetter from operator import attrgetter
from django.db import IntegrityError from django.db import IntegrityError
@ -7,7 +7,7 @@ from django.test import TestCase
from .models import ( from .models import (
CustomMembership, Employee, Event, Friendship, Group, Ingredient, CustomMembership, Employee, Event, Friendship, Group, Ingredient,
Invitation, Membership, Person, PersonSelfRefM2M, Recipe, RecipeIngredient, Invitation, Membership, Person, PersonSelfRefM2M, Recipe, RecipeIngredient,
Relationship, Relationship, SymmetricalFriendship,
) )
@ -401,7 +401,7 @@ class M2mThroughReferentialTests(TestCase):
attrgetter("name") attrgetter("name")
) )
def test_self_referential_symmetrical(self): def test_self_referential_non_symmetrical_both(self):
tony = PersonSelfRefM2M.objects.create(name="Tony") tony = PersonSelfRefM2M.objects.create(name="Tony")
chris = PersonSelfRefM2M.objects.create(name="Chris") chris = PersonSelfRefM2M.objects.create(name="Chris")
Friendship.objects.create( Friendship.objects.create(
@ -439,6 +439,71 @@ class M2mThroughReferentialTests(TestCase):
attrgetter('name') attrgetter('name')
) )
def test_self_referential_symmetrical(self):
tony = PersonSelfRefM2M.objects.create(name='Tony')
chris = PersonSelfRefM2M.objects.create(name='Chris')
SymmetricalFriendship.objects.create(
first=tony, second=chris, date_friended=date.today(),
)
self.assertSequenceEqual(tony.sym_friends.all(), [chris])
# Manually created symmetrical m2m relation doesn't add mirror entry
# automatically.
self.assertSequenceEqual(chris.sym_friends.all(), [])
SymmetricalFriendship.objects.create(
first=chris, second=tony, date_friended=date.today()
)
self.assertSequenceEqual(chris.sym_friends.all(), [tony])
def test_add_on_symmetrical_m2m_with_intermediate_model(self):
tony = PersonSelfRefM2M.objects.create(name='Tony')
chris = PersonSelfRefM2M.objects.create(name='Chris')
date_friended = date(2017, 1, 3)
tony.sym_friends.add(chris, through_defaults={'date_friended': date_friended})
self.assertSequenceEqual(tony.sym_friends.all(), [chris])
self.assertSequenceEqual(chris.sym_friends.all(), [tony])
friendship = tony.symmetricalfriendship_set.get()
self.assertEqual(friendship.date_friended, date_friended)
def test_set_on_symmetrical_m2m_with_intermediate_model(self):
tony = PersonSelfRefM2M.objects.create(name='Tony')
chris = PersonSelfRefM2M.objects.create(name='Chris')
anne = PersonSelfRefM2M.objects.create(name='Anne')
kate = PersonSelfRefM2M.objects.create(name='Kate')
date_friended_add = date(2013, 1, 5)
date_friended_set = date.today()
tony.sym_friends.add(
anne, chris,
through_defaults={'date_friended': date_friended_add},
)
tony.sym_friends.set(
[anne, kate],
through_defaults={'date_friended': date_friended_set},
)
self.assertSequenceEqual(tony.sym_friends.all(), [anne, kate])
self.assertSequenceEqual(anne.sym_friends.all(), [tony])
self.assertSequenceEqual(kate.sym_friends.all(), [tony])
self.assertEqual(
kate.symmetricalfriendship_set.get().date_friended,
date_friended_set,
)
# Date is preserved.
self.assertEqual(
anne.symmetricalfriendship_set.get().date_friended,
date_friended_add,
)
# Recreate relationship.
tony.sym_friends.set(
[anne],
clear=True,
through_defaults={'date_friended': date_friended_set},
)
self.assertSequenceEqual(tony.sym_friends.all(), [anne])
self.assertSequenceEqual(anne.sym_friends.all(), [tony])
self.assertEqual(
anne.symmetricalfriendship_set.get().date_friended,
date_friended_set,
)
class M2mThroughToFieldsTests(TestCase): class M2mThroughToFieldsTests(TestCase):
@classmethod @classmethod