Avoiding Generic Foreign Keys by using Check Constraints

Abenezer Belachew

Abenezer Belachew · September 05, 2024

4 min read

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:

models.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:

models.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

models.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):

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.

shell
>>> 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).
psql
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.

✌️