Using Django Check Constraints to Prevent the Storage of The Empty String

2021-01-31 Go team dog!

Here’s another use case for creating a database constraint with Django’s CheckConstraint class.

By default, a model CharField will store any string that the database supports. This includes the empty string, which is often inappropriate.

For example, imagine we have a Team model, representing a group that users can belong to. We started by giving it a unique name:

from django.db import models


class Team(models.Model):
    name = models.CharField(max_length=120)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                name="%(app_label)s_%(class)s_name_unique",
                fields=["name"],
            ),
        ]

Whilst seemingly reasonable, this model definition allows the creation of teams with an empty name. Such a team object is likely to break other parts of our application. For example, links might not display or unexpected errors may occur due to name being falsey.

We could try to prevent the creation of empty-named teams with form validation, but that does not prevent creation of bad data through other processes, such as bulk imports. To ensure all the data in our system is valid, we need a database constraint.

Here we want to add a minimal database constraint that ensures the length of name is greater than 0. Let’s walk through that now.

First, we need to register the Length function for use with CharField. This makes it available as a transform or lookup, like the default lookups such as <field>__gt for “field greater than”. Registering the function requires only a single function call:

from django.db import models
from django.db.models.functions import Length

models.CharField.register_lookup(Length)

We can add this at the top of our models file. Django has many such functions we can register as extra” lookups/transforms - check out other references to register_lookup() in the database functions documentation.

Second, we add our new constraint to Meta.constraints. We use a Q() object to represent what data is valid - where the name length is greater than 0:

from django.db import models
from django.db.models.functions import Length

models.CharField.register_lookup(Length)


class Team(models.Model):
    name = models.CharField(max_length=120)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                name="%(app_label)s_%(class)s_name_unique",
                fields=["name"],
            ),
            models.CheckConstraint(
                name="%(app_label)s_%(class)s_name_not_empty",
                check=models.Q(name__length__gt=0),
            ),
        ]

We’d then run makemigrations to generate a new migration:

$ ./manage.py makemigrations core
Migrations for 'core':
  example/core/migrations/0002_team_core_team_name_not_empty.py
    - Create constraint core_team_name_not_empty on model team

This migration has only the one step, adding the new constraint:

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ("core", "0001_initial"),
    ]

    operations = [
        migrations.AddConstraint(
            model_name="team",
            constraint=models.CheckConstraint(
                check=models.Q(name__length__gt=0),
                name="core_team_name_not_empty",
            ),
        ),
    ]

Third, we can write a test. I like to do this for all check constraints to ensure they do what I meant. Here our test tries to create a bad team, and asserts that the database raises an IntegrityError mentioning the name of our constraint:

from django.db import IntegrityError
from django.test import TestCase

from example.core.models import Team


class TeamTests(TestCase):
    def test_name_non_empty(self):
        constraint_name = "core_team_name_not_empty"
        with self.assertRaisesMessage(IntegrityError, constraint_name):
            Team.objects.create(name="")

Fourth, before we can deploy our change, we need to check that there is no empty-named team in our production database. If such a team does exist, the database will fail to apply the migration, since constraints apply to all data.

We can do this with ./manage.py shell on production:

In [1]: from example.core.models import Team

In [2]: Team.objects.filter(name="").count()
Out[2]: 0

Great, zero results. If such a team existed, we’d want to fix it, perhaps by renaming or deleting it.

Fifth, we’d want to add some extra user-facing validation, such as in forms or API endpoints. This can present a nice error message to the user if they attempt to create such a team. Without such validation attempts to use the empty string will crash:

In [3]: Team.objects.create(name="")
---------------------------------------------------------------------------
IntegrityError                            Traceback (most recent call last)
...
IntegrityError: CHECK constraint failed: core_team_name_not_empty

In the forms and DRF CharFields we can do this by declaring min_length=1.

Fin

May you store only valid data,

—Adam


🎉 My book Speed Up Your Django Tests is now up to date for Django 3.2. 🎉
Buy now on Gumroad


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: django