Использование Django Check Constraints для предотвращения хранения пустых строк

Перевод статьи Using Django Check Constraints to Prevent the Storage of The Empty String, опубликованный сайтом webdevblog.ru. В статье рассказывается об варианте использования Django класса CheckConstraint для создания ограничения в базе данных.


По умолчанию модель CharField хранит любую строку, поддерживаемую базой данных. Сюда входит и пустая строка, что часто неуместно.

Например, представьте, что у нас есть модель Team, представляющая группу, к которой могут принадлежать пользователи. Начнем с того, дадим ему уникальное имя 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"],
            ),
        ]

Хотя это определение модели кажется разумным, оно позволяет создавать записи с пустым name. Такой командный объект может нарушить работу других частей нашего приложения. Например, ссылки могут не отображаться или могут возникнуть непредвиденные ошибки из-за неправильного name.

Мы могли бы попытаться предотвратить создание команд с пустыми именами с помощью проверки формы, но это не предотвратит создание неверных данных с помощью других процессов, таких как массовый импорт. Чтобы гарантировать, что все данные в нашей системе действительны, нам нужно ограничение базы данных (database constraint).

Здесь мы хотим добавить минимальное ограничение базы данных, которое гарантирует, что длина name  больше нуля. Давайте рассмотрим как это сделать.

Во-первых, нам нужно зарегистрировать функцию Length для использования с CharField. Это сделает его доступным для преобразование или как поиск по умолчанию, такой как <field>__gt для «поле больше чем». Для регистрации функции требуется только один вызов функции:

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

models.CharField.register_lookup(Length)

Мы можем добавить это в начало файла наших моделей. В Django есть много таких функций, которые мы можем зарегистрировать как дополнительные поиски / преобразования — ознакомьтесь с другими ссылками на register_lookup () в документации по функциям базы данных.

Во-вторых, мы добавляем наше новое ограничение в Meta.constraints. Для этого используем объект Q (), чтобы представить, какие данные действительны — если длина имени больше 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),
            ),
        ]

Затем мы запустили makemigrations, чтобы создать новую миграцию:

$ ./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

Эта миграция состоит только из одного шага с добавлением нового ограничения:

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",
            ),
        ),
    ]

В-третьих, мы можем написать тест. Мне нравится делать это для всех проверочных ограничений, чтобы убедиться, что они делают то, что я имел в виду. Здесь наш тест пытается создать плохую команду и проверить (assert), что база данных вызывает IntegrityError с упоминанием имени нашего ограничения:

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="")

В-четвертых, прежде чем мы сможем развернуть наше изменение, нам нужно убедиться, что в нашей производственной базе данных нет команды с пустым именем. Если такая группа действительно существует, база данных не сможет применить миграцию, поскольку ограничения применяются ко всем данным.

Мы можем сделать это с помощью оболочки:

$ ./manage.py shell
Python 3.9.1 (default, Jan 21 2021, 09:04:53)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.19.0 -- An enhanced Interactive Python. Type '?' for help.

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

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

Отлично, нулевой результат. Если бы такая команда существовала, мы бы хотели ее исправить, возможно, переименовав или удалив ее.

В-пятых, желательно добавить дополнительную проверку, ориентированную на пользователя, например в формах или конечных точках API. Это может стать хорошим сообщением об ошибке для пользователя, если он попытается создать такую команду. Без такой проверки попытки использования пустой строки завершатся сбоем:

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

В формах и DRF CharFields мы можем сделать это, объявив min_length = 1.

Теперь вы можете хранить только валидные данные.