Avoiding Generic Foreign Keys by using Check Constraints
Abenezer Belachew · September 05, 2024
4 min read
This post is influenced by these two articles:
- Avoid Django's GenericForeignKey by Luke Plant and
- Using Django Check Constraints to Ensure Only One Field Is Set by Adam Johnson.
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:
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
:
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
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.
>>> 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).
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.
✌️