Opened 3 years ago

Closed 3 years ago

#33209 closed Bug (invalid)

ManyToManyField.add() doesn't respect a unique constraint in intermediate table

Reported by: Yuta Okamoto Owned by: nobody
Component: Database layer (models, ORM) Version: 3.1
Severity: Normal Keywords:
Cc: Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Yuta Okamoto)

I added the following Company and Member models, and CompanyMember as their intermediate table with a unique constraint including role field in addition to through_fields.

from django.db import models

class Company(models.Model):
    pass

class Member(models.Model):
    companies = models.ManyToManyField(Company, through='CompanyMember', through_fields=('company', 'member'), related_name='members')

class CompanyMember:
    company = models.ForeignKey(Company, on_delete=models.CASCADE)
    member = models.ForeignKey(Member, on_delete=models.CASCADE)
    role = models.SmallIntegerField()

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=['company', 'member', 'role'], name='company_member_role'),
        ]

In this situation, company.members.add() silently fails to add existing member with different role specified via through_defaults.

company = Company.objects.create()
member = Member.objects.create()

company.members.add(member, through_defaults={'role': 1})
assert company.members.through.objects.all().count() == 1

company.members.add(member, through_defaults={'role': 2})
assert company.members.through.objects.all().count() == 2  # fails

We need to workaround by adding the relation to the intermediate table directly.

company.members.through.objects.create(company=company, member=member, role=2)

Change History (7)

comment:1 by Yuta Okamoto, 3 years ago

Description: modified (diff)

comment:2 by Yuta Okamoto, 3 years ago

Description: modified (diff)

comment:3 by Yuta Okamoto, 3 years ago

Description: modified (diff)

comment:4 by Yuta Okamoto, 3 years ago

Description: modified (diff)

comment:5 by Yuta Okamoto, 3 years ago

Description: modified (diff)

comment:6 by Simon Charette, 3 years ago

I'll let others chime in but I think this is invalid.

ManyToManyField was designed to allow only a single instance of the relationship it defines and not allow extra dimensions to be considered. In your case that means a single instance of the Member <-> Company many-to-many relationship can be tracked at a time and the role dimension is not taken into account at all.

If you want to keep using ManyToManyField for this purpose you'll likely need to tweak your data model a bit

e.g.

class Company(models.Model):
    pass

class Role(models.Model):
    company = models.ForeignKey(Company, related_name='roles')

class Member(models.Model):
    roles = models.ManyToManyField(Role, related_name='members')

   @property
   def companies(self):
       return Company.objects.filter(roles__members=self)

comment:7 by Mariusz Felisiak, 3 years ago

Resolution: invalid
Status: newclosed

I agree with Simon. As documented "Using add() on a relation that already exists won’t duplicate the relation,..." and in your case member and company are already related.

Note: See TracTickets for help on using tickets.
Back to Top