Photo by Jonas Kakaroto on Unsplash

Django tricks: Polymorphism with Generic Foreign Keys

Adrien Van Thong

--

One of the many powerful features Django brings to the mix as an ORM is the ability to introduce polymorphism to our models — a concept which becomes crucially important as applications grow and developers look to reduce repeated and boilerplate code.

In this article I’ll cover what a Generic Foreign Key is, how to set it up, and where it is most useful.

Foreign Keys

In Django, a developer can create a relationship between two concrete tables using foreign keys. These relationships (One-to-One, One-to-many, Many-to-Many) are established between two specific models. For example, UserProfile to User (one-to-one), Library to Books (One-to-many), Students to Courses (many-to-many).

In each of the examples above, the relationship is strictly between two specific models, but what about in the case where we want to associate one model with multiple other models?

For a specific example, let’s imagine we’re building a web app with a feature that allows users to leave reviews on many different items found in a wholesale retailer: Books, Fruits, Clothes, OfficeSupplies, etc.

One possible solution would be to add a different foreign key field on the Reviews model for each model that can be reviewed. This would provide a simple solution, but create further complications down the line in the form of unwieldly if-else statements each time the relationship need to be traversed.

Generic Foreign Keys

Enter, Generic Foreign Keys. As the name implies, these allow us to create a foreign key where each record can refer to any model, as opposed to just one.

Whereas a foreign key is usually just a single integer pointing towards a specific record in the related model, a generic foreign key also includes an extra field to track which model is being referenced.

Let’s take a look at what this may look like in practice:

from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType

class Review(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE)
comment = models.TextField()
rating = models.IntegerField()

# Fields below are for generic foreign key so that Reviews can be associated with different models.
content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(null=True, blank=True)
content_object = GenericForeignKey('content_type', 'object_id')

The first 3 fields are trivial — they describe the review in question: who wrote the review, what they rated it, and any comments they wrote.

The next 3 fields make up the generic foreign key itself:

  • The content_type describes which model this record references.
  • The object_id integer points to a specific record in the referenced model.
  • The content_object is how to traverse the relationship to the referenced model.

Generic Foreign Keys have a bit of extra work where we also have to update the models being referenced as well:

class Book(models.Model):
title = models.CharField(max_length=128)
author = models.CharField(max_length=128)
price = models.DecimalField()

# Link back to reviews:
reviews = GenericRelation('Review')

def __str__(self):
return f'{self.title}, by {self.author} (${self.price})'

class Fruit(models.Model):
name = models.CharField(max_length=64)
price = models.DecimalField()

# Link back to reviews:
reviews = GenericRelation('Review')

def __str__(self):
return f'{self.name} (${self.price})'

In the example above, both the Book and Fruit models can have reviews assigned to them through the Generic Foreign Key. Let’s explore what this looks like in practice

Using Generic Foreign Keys

Now that we’ve created the Review model and created the relationship to both the Fruit and Book models, let’s create some records and add some reviews to them.

Like a regular Foreign key, we can create a review directly from the referenced models (Fruit and Book), for example:

>>> from .models import Review, Fruit, Book
>>> banana = Fruit.objects.create(name='Banana', price=0.99)
>>> banana.reviews.create(author=User.objects.first(), comment='too sticky!', rating=1)
<Review: Review object (1)>
>>> banana.reviews.create(author=User.objects.last(), comment='Great source of Potasium', rating=5)
<Review: Review object (2)>
>>>
>>> moby_dick = Book.objects.create(title='Moby Dick (paperback)', author='Herman Melville', price='9.99')
>>> moby_dick.reviews.create(author=User.objects.first(), comment='Classic!', rating=5)
<Review: Review object (3)>

We can see we now have three reviews in total, spanning 2 records across 2 different models. We can traverse the relationship very easily in the other direction as well:

>>> Review.objects.all()
<QuerySet [<Review: Review object (1)>, <Review: Review object (2)>, <Review: Review object (3)>]>
>>> Review.objects.first()
<Review: Review object (1)>
>>> Review.objects.first().content_object
<Fruit: Banana ($0.99)>
>>> Review.objects.last().content_object
<Book: Moby Dick (paperback) by Herman Melville ($9.99)>

The content_object pointer lets us traverse the relationship in the other direction without having to worry about which model type might be on the other end.

Polymorphism

Example 1: Built-in methods

This is where things get really cool. We can leverage the content_object field and polymorphism to create an __str__ method which calls the referenced model’s __str__ method. For example, if we wanted the Review model’s __str__ method to print the rating along with the reviewed item’s name, we could do it as such:

class Review(models.Model):
[ ... ]

def __str__(self):
return f"{self.author} review ({self.rating}-star) of {self.content_object}"

When printing a Review object, we now get:

>>> Review.objects.first()
<Review: John Smith review (1-star) of Banana ($0.99)>

This can also be extremely useful as well for the get_absolute_url method, for example when displaying a review, you may want to leave a link to the page for the reviewed item, for example:

<h1>Review<h1> 
<a href="{{ review.content_object.get_absolute_url }}">
View item
</a><br />
<b>Author</b>: {{ review.author }}<br />
<b>Rating</b>: {{ review.rating }}-star<br />
<b>Comment</b>: <p>{{ review.comment }}</p>

Note that the link abstractly calls the referenced object’s get_absolute_url method. We can do this without knowing anything about the referenced object — it could be a Book, a Fruit, or even some model we haven’t yet created, this code will always produce a working URL to the correct model, provided we have defined our get_absolute_url methods on all our models.

Example 2: Fields

We can also apply this concept at the field-level. You’ll notice that both our Fruit and Book models have a price field on them. Just as before, we can leverage polymorphism to generically display any shared fields between the referenced models, for example:

{% if review.content_object.price %}
<b>Price</b>: ${{ review.content_object.price }}
{% endif %}

The if statement future-proofs us against any models being referenced in the future which may not have the price field defined.

If necessary, shared fields between referenced models can be enforced via Abstract Base Classes in Django, but that is a topic for another day.

Example 3: Aggregates

Let’s say we wanted to find out the number of reviews for each Fruit record:

>>> from django.db.models import Count
>>> all_fruits = Fruit.objects.annotate(num_reviews=Count('reviews'))
>>> all_fruits.first().num_reviews
2

The average review for each Fruit record:

>>> from django.db.models import Avg
>>> all_fruits = Fruit.objects.annotate(avg_rating=Avg('reviews__rating'))
>>> all_fruits.first().avg_rating
3.0

Unfortunately, Django cannot calculate aggregates in the other direction, so polymorphism here is not possible. For example, we could calculate the average price for only Fruit records with a 5-star rating:

>>> from django.db.models import Avg
>>> Fruit.objects.filter(reviews__rating=5).aggregate(Avg('price'))
{'price__avg': Decimal('0.990000000000000')}

Conclusion

Generic Foreign Keys can quickly and elegantly fit within a number of use cases — primarily whenever a model can have a relation with multiple other models. Whether that be tracking tags on items, items in a shopping cart, comments on items, etc.

While Generic Foreign Keys do carry limitations and do come with a performance hit some queries, they enable key functionality and polymorphism within a growing code base.

Related Articles

--

--

No responses yet