Django ORM

Constraints и валидация | Курс Django ORM урок 6.3

Constraints и валидация | Курс Django ORM урок 6.3
Mikhail
Автор
Mikhail
Опубликовано 16.03.2026
0,0
Views 2

Цель урока

Разобрать ограничения целостности данных в 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. И почему параметризованные запросы обязательны для безопасности.


<< Урок 6.2

Урок 7.1 >>


Подписывайтесь на мой Telegram канал

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

Авторизуйтесь, чтобы оставить комментарий.

Комментариев: 0

Нет комментариев.

Тут может быть ваша реклама

Пишите info@aisferaic.ru

Похожие туториалы