Search code examples
pythondjangodjango-modelsmany-to-manydjango-orm

How to make a recursive ManyToMany relationship symmetrical with Django


I have read the Django Docs regarding symmetrical=True. I have also read this question asking the same question for an older version of Django but the following code is not working as the Django docs describe.

# people.models
from django.db import models


class Person(models.Model):
    name = models.CharField(max_length=255)
    friends = models.ManyToManyField("self",
                                     through='Friendship',
                                     through_fields=('personA', 'personB'),
                                     symmetrical=True,
                                     )

    def __str__(self):
        return self.name


class Friendship(models.Model):
    personA = models.ForeignKey(Person, on_delete=models.CASCADE, related_name='personA')
    personB = models.ForeignKey(Person, on_delete=models.CASCADE, related_name='personB')
    start = models.DateField(null=True, blank=True)
    end = models.DateField(null=True, blank=True)

    def __str__(self):
        return ' and '.join([str(self.personA), str(self.personB)])

If bill and ted are friends, I would expect bill.friends.all() to included ted, and ted.friends.all() to include bill. This is not what happens. bill's query includes ted, but ted's query does not include bill.

>>> from people.models import Person, Friendship
>>> bill = Person(name='bill')
>>> bill.save()
>>> ted = Person(name='ted')
>>> ted.save()
>>> bill_and_ted = Friendship(personA=bill, personB=ted)
>>> bill_and_ted.save()
>>> bill.friends.all()
<QuerySet [<Person: ted>]>
>>> ted.friends.all()
<QuerySet []>
>>> ted.refresh_from_db()
>>> ted.friends.all()
<QuerySet []>
>>> ted = Person.objects.get(name='ted')
>>> ted.friends.all()
<QuerySet []>

Is this a bug or am I misunderstanding something?

EDIT: Updated code to show the behavior is the same with through_fields set.


Solution

  • The proper way to add the relationship is bill.friends.add(ted). This will make bill friends with ted and ted friends with bill. If you want to set values for the extra fields on the intermediate model, in my case start and end, use the through_defaults argument for add().

    ...
    >>> bill.friends.add(ted, through_defaults={'start': datetime.now()}
    

    There are cases where you want the relationship between bill -> ted to have different values on the intermediate model than ted -> bill. For example, bill thinks ted is "cool", when they first meet, but ted thinks bill is "mean". You'll need helper function in that case.

    # people.models
    from django.db import models
    
    
    class Person(models.Model):
        name = models.CharField(max_length=255)
        friends = models.ManyToManyField("self", through='Friendship')
    
        def __str__(self):
            return self.name
    
        def add_friendship(self, person, impressionA, impressionB, recursive=True):
            self.friends.add(person, through_defaults={'personA_impression': impressionA, 'personB_impression': impressionB)
            if recursive:
                person.add_friendship(self, impressionB, impressionA, False)
    
    class Friendship(models.Model):
        personA = models.ForeignKey(Person, on_delete=models.CASCADE, related_name='a')
        personB = models.ForeignKey(Person, on_delete=models.CASCADE, related_name='b')
        personA_impression = models.CharField(max_length=255)
        personB_impression = models.CharField(max_length=255)
    
        def __str__(self):
            return ' and '.join([str(self.personA), str(self.personB)])
    

    Calling bill.friends.add(ted, through_defaults={"personA_impression": "cool", "personB_impression": "mean"}) results in the following:

    ...
    >>> bill_and_ted = Friendship.objects.get(personA=bill)
    >>> ted_and_bill = Friendship.objects.get(personA=ted)
    >>> bill_and_ted.personA_impression
    "cool"  # bill thinks ted is cool
    >>> bill_and_ted.personB_impression
    "mean"  # ted thinks bill is mean
    >>> ted_and_bill.personA_impression
    "cool"  # ted thinks bill is cool. This contradicts the bill_and_ted intermediate model
    

    Using the add_friendship function assigns the proper values to the fields.