Выбор полей (сhoices) Django в действительности не ограничивает ваши данные
Перевод: Adam Johnson — Django’s Field Choices Don’t Constrain Your Data
Этот статья описывает несколько не очевидный способ работы Field.choices в Django.
Возьмем для примера определение модели Django:
from django.db import models class Status(models.TextChoices): UNPUBLISHED = 'UN', 'Unpublished' PUBLISHED = 'PB', 'Published' class Book(models.Model): status = models.CharField( max_length=2, choices=Status.choices, default=Status.UNPUBLISHED, ) def __str__(self): return f"{self.id} - {Status(self.status).label}"
Если мы откроем shell, мы можем легко создать Book с заданным выбором status:
$ python manage.py shell # with ipython installed ... In [1]: from core.models import Status, Book In [2]: Book.objects.create(status=Status.UNPUBLISHED) Out[2]: <Book: 1 - Unpublished>
Список вариантов (choices) ограничивает значение status
во время проверки модели (model validation) в Python:
In [3]: book = Book.objects.get(id=1) In [4]: book.status = 'republished' In [5]: book.full_clean() --------------------------------------------------------------------------- ValidationError Traceback (most recent call last) <ipython-input-7-e64237e0a92a> in <module> ----> 1 book.full_clean() .../django/db/models/base.py in full_clean(self, exclude, validate_unique) 1220 1221 if errors: -> 1222 raise ValidationError(errors) 1223 1224 def clean_fields(self, exclude=None): ValidationError: {'status': ["Value 'republished' is not a valid choice."]}
И это отлично подходит для ModelForms и других случаев с использованием проверки. Пользователи не могут выбирать неправильные варианты и получать сообщения об ошибки если попробуют записать неверные данные.
К сожалению, нам, как разработчикам, по-прежнему легко записать эти недопустимые данные в базу данных:
In [6]: book.save()
Опс!
Также возможно обновить все наши экземпляры до недопустимого значения status через однострочную команду:
In [8]: Book.objects.update(status='republished') Out[8]: 1
Итак, что происходит? Почему Django позволяет нам объявлять набор вариантов, которые мы хотим, чтобы поле принимало, а затем позволяет нам легко обойти это?
Что ж, проверка модели в Django предназначена в основном для форм. Он полагается, что другой код в вашем приложении «знают, что делает».
Если мы хотим предотвратить это, самое общее решение — заставить саму базу данных отклонять неверные данные. Это не только сделает ваш код Django более надежным, но и любые другие приложения, использующие базу данных, также будут использовать ограничения.
Мы можем добавить такие ограничения с помощью класса CheckConstraint
, добавленного в Django 2.2. Для нашей модели нам нужно определить и назвать единственный фильтр CheckConstraint в Meta.constraints:
class Book(models.Model): status = models.CharField( max_length=2, choices=Status.choices, default=Status.UNPUBLISHED, ) def __str__(self): return f"{self.id} - {Status(self.status).label}" class Meta: constraints = [ models.CheckConstraint( name="%(app_label)s_%(class)s_status_valid", check=models.Q(status__in=Status.values), ) ]
Объект Q представляет собой одно выражение, которое мы передаем в Model.objects.filter (). Ограничения могут иметь любое количество логики для полей в текущей модели. Это включает в себя все виды поиска, сравнения между полями и функции базы данных.
Запустив makemigrations, мы получим миграцию, которая выглядит так:
from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("core", "0001_initial"), ] operations = [ migrations.AddConstraint( model_name="book", constraint=models.CheckConstraint( check=models.Q(status__in=["UN", "PB"]), name="%(app_label)s_%(class)s_status_valid", ), ), ]
Если мы попытаемся применить ее, пока база данных содержит недопустимые данные, то мы получим ошибку:
$ python manage.py migrate Operations to perform: Apply all migrations: core Running migrations: Applying core.0002_book_status_valid...Traceback (most recent call last): ... File "/.../django/db/backends/sqlite3/base.py", line 396, in execute return Database.Cursor.execute(self, query, params) django.db.utils.IntegrityError: CHECK constraint failed: status_valid
Если мы очистим эти данные вручную и попробуем снова, они пройдут:
$ python manage.py migrate Operations to perform: Apply all migrations: core Running migrations: Applying core.0002_book_status_valid... OK
С этого момента база данных не позволит нам вставлять недопустимые строки или обновлять допустимые строки, чтобы они были недопустимыми:
In [4]: book.save() --------------------------------------------------------------------------- ... /.../django/db/backends/sqlite3/base.py in execute(self, query, params) 394 return Database.Cursor.execute(self, query) 395 query = self.convert_query(query) --> 396 return Database.Cursor.execute(self, query, params) 397 398 def executemany(self, query, param_list): IntegrityError: CHECK constraint failed: status_valid In [5]: Book.objects.update(status='republished') --------------------------------------------------------------------------- ... /.../django/db/backends/sqlite3/base.py in execute(self, query, params) 394 return Database.Cursor.execute(self, query) 395 query = self.convert_query(query) --> 396 return Database.Cursor.execute(self, query, params) 397 398 def executemany(self, query, param_list): IntegrityError: CHECK constraint failed: status_valid
Супер!
В настоящее время Django не может отображать ошибки IntegrityErrors для пользователей при проверке модели. Ничто не сможет их уловить и превратить в ValidationErrors, которые могут нести сообщения, обращенные к пользователю. Согласно документации:
В общем случае ограничения не проверяются во время full_clean () и не вызывают ValidationErrors.
Чтобы это исправить, есть open ticket #30581.
В нашем случае, поскольку мы все еще используем choices, это нормально. Проверка сама по себе не позволит пользователям выбирать недопустимые статусы.
Для более сложных ограничений можно продублировать логику в Python с помощью custom validator.