Code


Version 1 (modified by Marty Alchin <gulopine@…>, 7 years ago) (diff)

Initial document

While the existing ManyToManyField is suitable for basic relationships, some projects find need to tie objects together along with some information about their relationship. The common example is the role an actor played in a movie, but it could be used for many other things, including what "base" a dating couple has gotten to, for instance. For these cases, the common recommendation is to simply create an intermediary model with ForeignKeys to each of the connected models, along with any extra fields that are appropriate for the relationship.

In the Hollywood example, something like this:

class Actor(models.Model):
    name = models.CharField(maxlength=255)

class Movie(models.Model):
    title = models.CharField(maxlength=255)
    actors = models.ManyToManyField(Actor)

would become something like this instead, with the role field added:

class Actor(models.Model):
    name = models.CharField(maxlength=255)

class Film(models.Model):
    title = models.CharField(maxlength=255)

class Role(models.Model):
    actor = models.ForeignKey(Actor, related_name='roles')
    film = models.ForeignKey(Film, related_name='roles')
    role = models.CharField(maxlength=255)

Unfortunately, the database API then goes from this:

>>> for actor in film.actors.all():
...     print actor.name
Graham Chapman
Terry Gilliam
John Cleese
Eric Idle

to this:

>>> for role in film.roles.all():
...     print '%s played %s' % (role.actor.name, role.role)
Graham Chapman played King Arthur
Terry Gilliam played Sir Bors
John Cleese played Sir Lancelot the Brave
Eric Idle played Sir Robin the Not-Quite-So-Brave-as-Sir Launcelot

While programmers may not have a problem with this change, it is very counter-intuitive for template authors who may not have (nor should they need) an intimate understanding of how relational databases work. I'd like to propose an alternative, using a custom manager for these intermediary models, that will enable a more natural API.

By simply adding one line and renaming the related_names:

class Actor(models.Model):
    name = models.CharField(maxlength=255)

class Film(models.Model):
    title = models.CharField(maxlength=255)

class Role(models.Model):
    actor = models.ForeignKey(Actor, related_name='films')
    film = models.ForeignKey(Film, related_name='actors')
    role = models.CharField(maxlength=255)

    objects = models.ManyToManyManager()

we could get a much more intuitive API:

>>> for actor in film.actors.all():
...     print '%s played %s' % (actor.name, actor.role)
Graham Chapman played King Arthur
Terry Gilliam played Sir Bors
John Cleese played Sir Lancelot the Brave
Eric Idle played Sir Robin the Not-Quite-So-Brave-as-Sir Launcelot

This would work just as well in both directions:

>>> for film in john.films.all():
...     print '%s in %s' % (film.role, film.title)
Sir Lancelot the Brave in Monty Python and the Holy Grail
Sir Nicholas de Mimsy-Porpington in Harry Potter and the Sorceror's Stone

Ideally, this API would also extend the add method of the manager, allowing it to take keyword attributes for the relationship meta-data:

>>> film = Film.objects.create(title='And Now for Something Completely Different')
>>> john.films.add(film, role='Sir George Head')
>>> for film in john.films.all():
...     print '%s in %s' % (film.role, film.title)
Sir Lancelot the Brave in Monty Python and the Holy Grail
Sir Nicholas de Mimsy-Porpington in Harry Potter and the Sorceror's Stone
Sir George Head in And Now for Something Completely Different

And without the intermediary model, there's need for an update function on the manager, which would handle modifying the meta-data:

>>> film.actors.update(john, role='Mungo the Cook')