Using Django Check Constraints to Prevent Self-Following

Another way to use Django’s CheckConstraint class to ensure your data is valid. Based on my answer to a question on the Django forum.
Imagine we have a user model that we’d like to introduce a social media “following” pattern to. Users can follow other users to receive updates on our site. We’d like to ensure that users do not follow themselves, since that would need special care in all our code.
We can block self-following in the UI layer, but there’s always the risk that we’ll accidentally enable it. For example, we might add an “import your contacts” feature that allows self-following.
As with all the other posts in this series - the best way to prevent bad data is to block it in the database.
Setting up the relationship
To add the followers relationship, we’ll be using ManyToManyField. By default, ManyToManyField creates a hidden model class to hold the relationships. Because we want to customize our model with add an extra constraint, we’ll need to use the through argument to define our own visible model class instead.
We can define a first version of this like so:
from django.db import models
class User(models.Model):
...
followers = models.ManyToManyField(
to="self",
through="Follow",
related_name="following",
symmetrical=False,
)
class Follow(models.Model):
from_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
to_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="+")
class Meta:
constraints = [
models.UniqueConstraint(
name="%(app_label)s_%(class)s_unique_relationships",
fields=["from_user", "to_user"],
),
]
Note:
- We use
to="self"to define that the relationship is fromUserto itself. Django calls this a recursive relationship. - We use the string format of
through, because we’re definingUserbeforeFollow. We could defineFollowfirst, but then we’d need to use strings to specifyUserin its definition. - We declare the relationship as asymmetrical with
symmetrical=False. If Alice follows Bob, it does not mean Bob follows Alice. If our relationship was a mutual “friend request” style, we would instead make the relationship symmetrical.
The
Followclass uses two foreign keys to link up the related users.ManyToManyFieldwill automatically use the first foreign key as the “source” of the relationship and the other as the destination.It’s possible
Followcould have a third foreign key toUser, for example to track another user who suggested the follow. In this case, we’d need to useManyToManyField.through_fieldsto specify which foreign keys actually form the relationship.We have already added a constraint to the model - a
UniqueConstraintto ensure that exactly one relationship exists between users. Without this, multiple follows could exist between e.g. Alice and Bob, and it would be confusing what that means. This is copying what Django’s default hiddenthroughmodel.We use string interpolation in our constraint’s name to namespace it to our model. This prevents naming collisions with constraints on other models. Databases have only one namespace for constraints across all tables, so we need to be careful.
After adding a migration and running it, we can try our models out on the shell:
In [1]: from example.core.models import User
In [2]: user1 = User.objects.create()
In [3]: user2 = User.objects.create()
In [4]: user1.following.add(user2)
In [5]: user1.following.add(user1)
In [6]: user1.following.all()
Out[6]: <QuerySet [<User: User object (2)>, <User: User object (1)>]>
Note we were still able to have user1 follow itself. Let’s fix that now.
Preventing self-follows
It’s time to set up our CheckConstraint.
First, we add the constraint in Meta.constraints:
class Follow(models.Model):
...
class Meta:
constraints = [
...,
models.CheckConstraint(
name="%(app_label)s_%(class)s_prevent_self_follow",
check=~models.Q(from_user=models.F("to_user")),
),
]
Our check here declares that the from_user field is not equal to the to_user field. We use an F() object to represent the to_user field. We negate the condition with the easy-to-miss squiggle operator (~), which turns the == into !=. The condition is therefore from_user != to_user in Python syntax.
Second, we run makemigrations to generate a new migration:
$ ./manage.py makemigrations core
Migrations for 'core':
example/core/migrations/0003_auto_20210225_0339.py
- Create constraint core_follow_prevent_self_follow on model follow
We check the migration looks correct:
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0002_auto_20210225_0320"),
]
operations = [
migrations.AddConstraint(
model_name="follow",
constraint=models.CheckConstraint(
check=models.Q(_negated=True, from_user=models.F("to_user")),
name="core_follow_prevent_self_follow",
),
),
]
So far so good!
Third, we can add a test that checks our check constraint works as intended:
from django.db import IntegrityError
from django.test import TestCase
from example.core.models import Follow, User
class FollowTests(TestCase):
def test_no_self_follow(self):
user = User.objects.create()
constraint_name = "core_follow_prevent_self_follow"
with self.assertRaisesMessage(IntegrityError, constraint_name):
Follow.objects.create(from_user=user, to_user=user)
The test tries to self-follow and ensures that this triggers a database error which contains the name of our constraint.
Fourth, we need to consider how our migration will work with existing bad data. If we try and migrate whilst self-follow relationships exist, the migration will fail with an IntegrityError:
$ ./manage.py migrate
Operations to perform:
Apply all migrations: core
Running migrations:
Applying core.0003_auto_20210225_0339...Traceback (most recent call last):
File "/.../django/db/backends/utils.py", line 84, in _execute
...
File "/.../django/db/backends/sqlite3/base.py", line 413, in execute
return Database.Cursor.execute(self, query, params)
django.db.utils.IntegrityError: CHECK constraint failed: core_follow_prevent_self_follow
We can delete any self-follow relationships in the migration with a RunPython operation. This allows us to use the ORM to modify the database. We can add a module-level function for RunPython and then reference it in the operation:
from django.db import migrations, models
def forwards_func(apps, schema_editor):
Follow = apps.get_model("core", "Follow")
db_alias = schema_editor.connection.alias
Follow.objects.using(db_alias).filter(from_user=models.F("to_user")).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0002_auto_20210225_0320"),
]
operations = [
migrations.RunPython(
code=forwards_func,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
migrations.AddConstraint(
model_name="follow",
constraint=models.CheckConstraint(
check=models.Q(_negated=True, from_user=models.F("to_user")),
name="core_follow_prevent_self_follow",
),
),
]
Note:
We use the same template for
forwards_funcas in theRunPythondocumentation.We fetch the point-in-history version of the
Followmodel throughapps.get_model(), rather than importing the latest version. Using the latest version would fail since it could reference fields that haven’t been added to the database yet.We also use the current database alias. It’s best to this even if our project only uses a single database, in case it gains multiple in the future.
We declare
reverse_codeas a no-op, so that this migration is reversible. Reversing the migration won’t be able to restore deleted self-follow relationships because we aren’t backing them up anywhere.We declare the operation as elidable. This means Django can drop the operation when squashing the migration history. This is always worth considering when writing a
RunPythonorRunSQLoperation, as it helps you make smaller, faster squashes.
Now we can run the migration without problem:
$ ./manage.py migrate
Operations to perform:
Apply all migrations: core
Running migrations:
Applying core.0003_auto_20210225_0339... OK
Great!
Fifth, we should step through our UI and API and check the ability to self-follow is not exposed anywhere. If we have an API, we’d want to make sure it returns a sensible error message when attempting to self-follow, since otherwise it would just crash with an IntegrityError.
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts:
- Using Django Check Constraints to Limit A Model to a Single Instance
- Using Django Check Constraints to Prevent the Storage of The Empty String
- Django’s Field Choices Don’t Constrain Your Data
Tags: django