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"], ), ]
- We use
to="self"to define that the relationship is from
Userto itself. Django calls this a recursive relationship.
- We use the string format of
through, because we’re defining
Follow. We could define
Followfirst, but then we’d need to use strings to specify
Userin 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.
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.
Followcould have a third foreign key to
User, for example to track another user who suggested the follow. In this case, we’d need to use
ManyToManyField.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 hidden
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 : from example.core.models import User In : user1 = User.objects.create() In : user2 = User.objects.create() In : user1.following.add(user2) In : user1.following.add(user1) In : user1.following.all() Out: <QuerySet [<User: User object (2)>, <User: User object (1)>]>
Note we were still able to have
user1 follow itself. Let’s fix that now.
It’s time to set up our
First, we add the constraint in
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")), ), ]
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
!=. 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
$ ./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", ), ), ]
We use the same template for
forwards_funcas in the
We fetch the point-in-history version of the
apps.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.
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
RunSQLoperation, 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
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
You can’t follow yourself, but you can follow me on Twitter,
Learn how to make your tests run quickly in my book Speed Up Your Django Tests.
One summary email a week, no spam, I pinky promise.
- 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