Constraints и валидация | Курс Django ORM урок 6.3
Цель урока
Разобрать ограничения целостности данных в Django ORM: UniqueConstraint, CheckConstraint, условные (partial) ограничения. Понять разницу между валидацией на уровне Django и ограничениями на уровне БД. Научиться обрабатывать ошибки ограничений.
Необходимые знания
Два уровня защиты данных
Django предоставляет два независимых уровня валидации:
1. уровень Django (Model.full_clean(), валидаторы форм, DRF serializers):
- Выполняется в Python перед SQL
- Удобные сообщения об ошибках
- НЕ вызывается автоматически при
save(),create(),update() - Легко обойти через прямой SQL или ORM
2. Уровень БД (constraints в PostgreSQL):
- Выполняется PostgreSQL при каждом INSERT/UPDATE
- Нельзя обойти никак, это гарантия целостности
- Ошибки, исключения Python (
IntegrityError) - Менее удобны для UX
Правило: важные ограничения целостности всегда дублируйте на уровне БД. Валидация Django, для UX, DB constraint для гарантии.
UniqueConstraint
Обеспечивает уникальность комбинации полей. Создает UNIQUE индекс в PostgreSQL.
Простая уникальность
class Review(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
rating = models.PositiveSmallIntegerField()
text = models.TextField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["product", "user"],
name="unique_product_user_review",
)
]
ALTER TABLE shop_review
ADD CONSTRAINT unique_product_user_review UNIQUE (product_id, user_id);
Один пользователь, один отзыв на один продукт. PostgreSQL не даст вставить дубль.
condition - условная уникальность
Иногда нужна уникальность только для части строк. condition задает условие через Q:
class Product(models.Model):
slug = models.SlugField()
is_active = models.BooleanField(default=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["slug"],
condition=Q(is_active=True),
name="unique_active_product_slug",
)
]
CREATE UNIQUE INDEX unique_active_product_slug
ON shop_product (slug)
WHERE is_active = true;
Уникальность slug только среди активных продуктов. Можно иметь несколько удаленных/неактивных продуктов с одинаковым slug.
nulls_distinct - NULL и уникальность (Django 5.0+, PostgreSQL 15+)
В SQL NULL означает "значение неизвестно". По стандарту SQL и в PostgreSQL NULL != NULL, потому что нельзя сравнивать два неизвестных значения. Из-за этого UNIQUE constraint по умолчанию позволяет вставить сколько угодно строк с NULL: PostgreSQL считает их разными.
# Стандартное поведение, несколько NULL разрешены
class Order(models.Model):
tracking_number = models.CharField(max_length=100, null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tracking_number"],
name="unique_tracking",
),
]
Order.objects.create(tracking_number=None) # OK
Order.objects.create(tracking_number=None) # OK, NULL != NULL
Order.objects.create(tracking_number=None) # OK, снова
Order.objects.create(tracking_number="ABC") # OK
Order.objects.create(tracking_number="ABC") # IntegrityError: дубль
Для tracking_number такое поведение обычно правильное, несколько заказов без трек-номера это нормально, дубль трек-номера нет.
Но иногда нужно разрешить только одну строку с NULL. nulls_distinct=False говорит PostgreSQL считать NULL равным NULL при проверке уникальности:
# Django 5.0+, PostgreSQL 15+: только один NULL разрешён
class Order(models.Model):
tracking_number = models.CharField(max_length=100, null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tracking_number"],
nulls_distinct=False,
name="unique_tracking_no_nulls",
),
]
Order.objects.create(tracking_number=None) # OK
Order.objects.create(tracking_number=None) # IntegrityError: NULL уже есть
-- Стандартное поведение:
CREATE UNIQUE INDEX unique_tracking ON shop_order (tracking_number);
-- nulls_distinct=False:
CREATE UNIQUE INDEX unique_tracking_no_nulls
ON shop_order (tracking_number) NULLS NOT DISTINCT;
На PostgreSQL 14 и ниже NULLS NOT DISTINCT недоступен. Альтернатива, частичный индекс через condition=Q(tracking_number__isnull=False) он просто не индексирует NULL строки и не проверяет их уникальность.
deferrable - отложенная проверка
По умолчанию PostgreSQL проверяет ограничения сразу при каждом INSERT/UPDATE. deferrable позволяет отложить проверку до конца транзакции:
class Meta:
constraints = [
models.UniqueConstraint(
fields=["sort_order"],
deferrable=models.Deferrable.DEFERRED,
name="unique_sort_order",
)
]
ALTER TABLE shop_product
ADD CONSTRAINT unique_sort_order UNIQUE (sort_order)
DEFERRABLE INITIALLY DEFERRED;
Полезно при перестановке элементов, чтобы поменять местами sort_order=1 и sort_order=2, нужно временно нарушить уникальность. С DEFERRED, ограничение проверяется при COMMIT, а не при каждом UPDATE.
CheckConstraint
Проверяет произвольное логическое условие при INSERT и UPDATE.
class Product(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.IntegerField()
discount_price = models.DecimalField(max_digits=10, decimal_places=2, null=True)
class Meta:
constraints = [
# Цена должна быть положительной
models.CheckConstraint(
condition=Q(price__gt=0),
name="product_price_positive",
),
# Stock не может быть отрицательным
models.CheckConstraint(
condition=Q(stock__gte=0),
name="product_stock_non_negative",
),
# Скидочная цена должна быть меньше основной
models.CheckConstraint(
condition=Q(discount_price__isnull=True) | Q(discount_price__lt=F("price")),
name="discount_price_less_than_price",
),
]
ALTER TABLE shop_product
ADD CONSTRAINT product_price_positive CHECK (price > 0);
ALTER TABLE shop_product
ADD CONSTRAINT product_stock_non_negative CHECK (stock >= 0);
ALTER TABLE shop_product
ADD CONSTRAINT discount_price_less_than_price
CHECK (discount_price IS NULL OR discount_price < price);
F("price") в CheckConstraint.condition транслируется в ссылку на другой столбец.
violation_error_message
models.CheckConstraint(
condition=Q(price__gt=0),
name="product_price_positive",
violation_error_message="Цена продукта должна быть больше нуля",
)
Это сообщение используется в валидации Django (validate_constraints()), но не в SQL ошибке PostgreSQL. PostgreSQL возвращает свой текст.
Обработка ошибок ограничений
При нарушении constraint PostgreSQL бросает исключение, Django оборачивает его в IntegrityError:
from django.db import IntegrityError
try:
Review.objects.create(
product=product,
user=user,
rating=5,
text="Отлично",
)
except IntegrityError as e:
if "unique_product_user_review" in str(e):
raise ValidationError("Вы уже оставляли отзыв на этот товар")
raise # другая IntegrityError, пробрасываем
Имя constraint в сообщении PostgreSQL позволяет различать разные нарушения.
get_or_create как альтернатива
Для UniqueConstraint часто удобнее get_or_create:
review, created = Review.objects.get_or_create(
product=product,
user=user,
defaults={"rating": rating, "text": text},
)
if not created:
raise ValidationError("Вы уже оставляли отзыв на этот товар")
full_clean() и validate_constraints()
Django проверяет constraints на уровне Python с помощью двух методов:
# Проверяет validators, unique_for_date, max_length и т.д.
product.full_clean()
# Проверяет только Meta.constraints
product.validate_constraints()
# validate_constraints вызывается внутри full_clean()
full_clean() автоматически вызывается:
- В Django Admin при сохранении
- В ModelForm при валидации
- При создании через DRF serializer (если включено)
full_clean() НЕ вызывается автоматически при:
product.save()Product.objects.create()Product.objects.update()bulk_create()
Если нужна валидация на уровне Django при save(), вызывайте явно:
product = Product(name="", price=Decimal("-10"))
product.full_clean() # ValidationError: Имя обязательно, цена отрицательная
product.save()
Или переопределите save():
class Product(models.Model):
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
Но осторожно: full_clean() в save() ломает bulk_create() и update().
Django vs БД
Только валидация Django: для:
- Форматных проверок (email, URL, длина строки)
- Проверок не связанных с целостностью (пароль достаточно сложный)
- Там где UX важнее гарантий
Django + DB constraint: для:
- Уникальности (email пользователя, slug продукта)
- Бизнес-правил которые нельзя нарушить (цена > 0, stock >= 0)
- Всего что важно для целостности данных
Только DB constraint (без уровня Django), при:
- Работе с данными напрямую через SQL, миграции
- Импорте данных через bulk операции
ExclusionConstraint - специфичное для PostgreSQL ограничение
Для сложных ограничений используется ExclusionConstraint (только PostgreSQL):
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import RangeOperators
class Booking(models.Model):
room = models.ForeignKey("Room", on_delete=models.CASCADE)
period = DateTimeRangeField()
class Meta:
constraints = [
ExclusionConstraint(
name="no_overlapping_bookings",
expressions=[
("room", RangeOperators.EQUAL),
("period", RangeOperators.OVERLAPS),
],
)
]
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE booking ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (room_id WITH =, period WITH &&);
Это гарантирует что одна комната не может быть забронирована на пересекающиеся периоды.
Практическое задание
1) Добавьте в модель Review ограничение: rating должен быть от 1 до 5. Используйте CheckConstraint. Проверьте что PostgreSQL не даст вставить рейтинг 6.
2) Добавьте в модель Product условие: discount_price < price и discount_price > 0 (если задана). Используйте CheckConstraint с Q и F.
3) Добавьте UniqueConstraint с condition=Q(is_active=True) на поле slug у Product.
Проверьте:
- Нельзя создать двух активных продуктов с одинаковым slug
- Можно создать двух неактивных продуктов с одинаковым slug
4) Напишите функцию safe_create_review(product_id, user_id, rating, text) которая:
- Пытается создать отзыв
- При
IntegrityErrorс именемunique_product_user_review, возвращает существующий отзыв - При других ошибках, пробрасывает исключение
5) Используйте full_clean() + validate_constraints() для проверки объекта перед save(). Напишите тест который проверяет что нельзя создать продукт с отрицательной ценой через save().
Возможные ошибки
Надеяться только на валидацию Django
class Product(models.Model):
price = models.DecimalField(validators=[MinValueValidator(0.01)])
# Нет CheckConstraint, можно обойти через Product.objects.create(price=-1)
Validator проверяется только при full_clean(). save() и update() не вызывают validators.
Забыть миграцию после добавления constraint
# Добавили constraint в Meta, нужна миграция!
python manage.py makemigrations
python manage.py migrate
Constraint в коде Python не влияет на БД до применения миграции.
UniqueConstraint не заменяет unique=True на поле
class Product(models.Model):
slug = models.SlugField() # нет unique=True
class Meta:
constraints = [
models.UniqueConstraint(fields=["slug"], name="unique_slug"),
]
Это создает UNIQUE индекс через constraint, что эквивалентно unique=True. Но Model.validate_unique() Django не будет автоматически проверять через full_clean(), только validate_constraints(). Если нужна уровень Django проверка в формах, добавьте unique=True на поле.
IntegrityError при добавлении constraint на существующие данные
- В миграции, добавить UniqueConstraint на поле с дублями
- django.db.utils.IntegrityError: could not create unique index
Сначала нужна data migration которая исправит дубликаты, потом добавление constraint.
Связь со следующим уроком
В уроке 7.1 разберем Raw SQL, когда ORM недостаточно. Manager.raw() для SELECT с параметрами, connection.cursor() для произвольного SQL. И почему параметризованные запросы обязательны для безопасности.
Подписывайтесь на мой Telegram канал
Если вам нужен ментор и вы хотите научиться веб-разработке, узнать подробнее
Авторизуйтесь, чтобы оставить комментарий.
Нет комментариев.
Тут может быть ваша реклама
Пишите info@aisferaic.ru