Back to Articles
Articles

Avoiding Generic Foreign Keys by using Check Constraints

How to avoid using generic foreign keys by using check constraints

Cover image for Avoiding Generic Foreign Keys by using Check Constraints

This post is influenced by these two articles:

I encourage you to read both of them, as they provide a lot of context and background information that I won't repeat here. They are not that long, so you can read them in a few minutes.

But if you don't feel like reading them, the TL;DR of Luke's blog post is that you should avoid using GenericForeignKey in Django models for most cases, while the TL;DR of Adam's blog post is that you can use check constraints to ensure only one of two fields is set in a Django model.

Luke makes some very good points about why you're usually better off avoiding GenericForeignKey. He has even provided a couple of alternatives to go about it, which I thought were decent. This is just another alternative that I read about in Adam's blog post that could be applied in cases where you want/need to avoid using GenericForeignKey.

I am going to use the same models Luke used in his blog post, but I will use a different approach to avoid using GenericForeignKey.

Here are the models:

Pythonmodels.py
class Person(models.Model):
    name = models.CharField(max_length=100)

class Group(models.Model):
    name = models.CharField(max_length=100)
    creator = models.ForeignKey(Person, on_delete=models.CASCADE)

Now, let's assume you want to create a Task model that can be owned by either a Person or a Group, but not both. One way to achieve this is by using a GenericForeignKey:

Pythonmodels.py
class Task(models.Model):
    description = models.CharField(max_length=200)

    # owner_id and owner_type are combined into the GenericForeignKey
    owner_id = models.PositiveIntegerField()
    owner_type = models.ForeignKey(ContentType, on_delete=models.PROTECT)

    # owner will be either a Person or a Group (or perhaps
    # another model we will add later):
    owner = GenericForeignKey('owner_type', 'owner_id')

This method is what we are trying to avoid for the reasons mentioned in the linked blog post.

In his blog post, five alternatives were provided. Here's a sixth one:

Using Check Constraints

Pythonmodels.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class OwnerType(models.TextChoices):
    PERSON = 'P', _('Person')
    GROUP = 'G', _('Group')

class Task(models.Model):
    description = models.CharField(max_length=200)
    owner_type = models.CharField(
        max_length=1,
        choices=OwnerType.choices,
        default=OwnerType.PERSON,
    )
    owner_person = models.ForeignKey(Person, null=True, blank=True, on_delete=models.CASCADE)
    owner_group = models.ForeignKey(Group, null=True, blank=True, on_delete=models.CASCADE)

    class Meta:
        constraints = [
            models.CheckConstraint(
                check=(
                    models.Q(owner_type=OwnerType.PERSON, owner_group_id__isnull=True) |
                    models.Q(owner_type=OwnerType.GROUP, owner_person_id__isnull=True)
                ),
                name='%(app_label)s_%(class)s_only_one_owner',
            )
        ]

In this model, we have a CharField called owner_type that can be either 'P' for Person or 'G' for Group. We also have two ForeignKey fields, owner_person and owner_group, that are nullable. We then have a CheckConstraint that ensures that only one of owner_person or owner_group is set.

This code generates the following SQL constraints (in PostgreSQL):

SQL
BEGIN;
--
-- Create model Task
--
CREATE TABLE "gfk_task" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "description" varchar(200) NOT NULL, "owner_type" varchar(1) NOT NULL, "owner_group_id" bigint NULL, "owner_person_id" bigint NULL);
--
-- Create constraint gfk_task_only_one_owner on model task
--
ALTER TABLE "gfk_task" ADD CONSTRAINT "gfk_task_only_one_owner" CHECK ((("owner_group_id" IS NULL AND "owner_type" = 'P') OR ("owner_person_id" IS NULL AND "owner_type" = 'G')));
ALTER TABLE "gfk_task" ADD CONSTRAINT "gfk_task_owner_group_id_41844020_fk_gfk_group_id" FOREIGN KEY ("owner_group_id") REFERENCES "gfk_group" ("id") DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE "gfk_task" ADD CONSTRAINT "gfk_task_owner_person_id_a74f294a_fk_gfk_person_id" FOREIGN KEY ("owner_person_id") REFERENCES "gfk_person" ("id") DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "gfk_task_owner_group_id_41844020" ON "gfk_task" ("owner_group_id");
CREATE INDEX "gfk_task_owner_person_id_a74f294a" ON "gfk_task" ("owner_person_id");
COMMIT;

I just named my app gfk for this example. Ignore that.

Anyway, what I'm trying to say is, not only do you get app-level validation, but you also get database-level validation.

Pythonshell
>>> person1 = Person.objects.create(name="Drogba")
>>> task1 = Task.objects.create(description="Score goals", owner_type="G", owner_person=person1)

django.db.utils.IntegrityError: new row for relation "gfk_task" violates check constraint "gfk_task_only_one_owner"
DETAIL:  Failing row contains (1, Score goals, G, null, 2).
SQLpsql
django-tests=# INSERT INTO gfk_person (name) VALUES ('Drogba');
INSERT 0 1
django-tests=# select * from gfk_person;
 id |  name  
----+--------
  1 | Drogba
django-tests=# INSERT INTO gfk_task (description, owner_type, owner_person_id) VALUES ('Score goals', 'G', 1);
ERROR:  new row for relation "gfk_task" violates check constraint "gfk_task_only_one_owner"
DETAIL:  Failing row contains (2, Score goals, G, null, 1).

That's it. This solution works well for cases where a Task can be owned by either a Person or a Group, but not both. If you foresee a scenario where a task could have multiple types of owners or more complex relationships, you may need to extend this solution or consider another approach.

✌️