Поля, ограничения БД, миграции и жизненный цикл модели
Модель описывает данные, которые Django хранит в базе: столбцы, связи, индексы и ограничения. Но модель — не магический валидатор. Важно различать три уровня:
Эти уровни дополняют друг друга.
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.utils import timezone
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Черновик"
PUBLISHED = "published", "Опубликован"
ARCHIVED = "archived", "В архиве"
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=220, unique=True, allow_unicode=True)
content = models.TextField()
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="posts",
)
status = models.CharField(
max_length=20,
choices=Status,
default=Status.DRAFT,
)
views = models.PositiveBigIntegerField(default=0)
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "-published_at"]),
]
constraints = [
models.CheckConstraint(
condition=(
Q(status="published", published_at__isnull=False)
| ~Q(status="published")
),
name="published_post_has_date",
),
]
def __str__(self):
return self.title
def publish(self):
self.status = self.Status.PUBLISHED
self.published_at = timezone.now()
self.save(update_fields=["status", "published_at", "updated_at"])В примере unique=True уже создаёт ограничение уникальности для slug, поэтому второй UniqueConstraint на то же поле не нужен. Составной индекс соответствует частому запросу: опубликованные записи по дате.
PROTECT у автора выбран сознательно: случайное удаление пользователя не должно каскадно удалить публикации. В другом домене правильным может быть SET_NULL или CASCADE; универсального варианта нет.
| Задача | Поле | Практический нюанс |
|---|---|---|
| Короткий текст | CharField | Укажите осмысленный max_length |
| Большой текст | TextField | Не загружайте его в списках без необходимости |
| Денежная сумма | DecimalField | Не используйте FloatField для денег |
| Счётчик | PositiveBigIntegerField | Положительность лучше дополнительно защитить constraint при сложных обновлениях |
| Дата и время | DateTimeField | При USE_TZ=True работайте с aware datetime |
| Необязательный флаг | BooleanField(null=True) | Убедитесь, что третье состояние действительно имеет смысл |
| Структурированные данные | JSONField | Не превращайте JSON в замену нормальной схеме без причины |
EmailField, URLField и другие поля имеют validators, но обычный model.save() их не запускает. Проверка происходит в ModelForm, serializer или при явном вызове full_clean().
post = Post(**payload)
post.full_clean()
post.save()Даже full_clean() не отменяет database constraint. Между проверкой уникальности и INSERT другой запрос может вставить такое же значение, поэтому приложение должно быть готово обработать IntegrityError.
null и blank отвечают за разноеnull=True разрешает SQL NULL. blank=True разрешает пустое значение на уровне Django validation.
subtitle = models.CharField(max_length=200, blank=True)
reviewed_at = models.DateTimeField(null=True, blank=True)Для строк обычно достаточно пустой строки и blank=True. Одновременные NULL и "" создают два представления «нет значения». Исключение может быть оправдано, если поле уникальное и бизнес-смысл различает эти состояния.
Для изменяемого значения передают callable, а не готовый объект:
metadata = models.JSONField(default=dict)default={} создаст один общий словарь на уровне объявления поля. Аналогично, default=uuid.uuid4 передаёт функцию, а default=uuid.uuid4() вычисляет одно значение при импорте модуля.
Foreign key хранится на стороне «много»:
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="comments",
)
text = models.TextField()Удаление поста удалит его комментарии. Это разумно, если комментарий без поста не имеет смысла.
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
class Article(models.Model):
tags = models.ManyToManyField(Tag, related_name="articles", blank=True)Если у связи появляются собственные данные — роль, дата добавления, порядок — нужна явная through-модель с UniqueConstraint.
class Membership(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
team = models.ForeignKey("Team", on_delete=models.CASCADE)
role = models.CharField(max_length=20)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "team"],
name="unique_team_member",
),
]One-to-one подходит, когда дополнительная запись может существовать не у каждого объекта. Не создавайте отдельный Profile только по привычке: несколько полей часто проще положить в custom user model в начале проекта.
Простой slugify(title) не гарантирует уникальность. Два одинаковых заголовка дадут одинаковый slug, а для нелатинского текста поведение зависит от allow_unicode и выбранной транслитерации.
Практические варианты:
Не стоит прятать сложный алгоритм разрешения коллизий в save() без тестов: bulk-операции этот метод обходят.
Код post.views += 1; post.save() теряет обновления при двух одновременных запросах. Вычисление нужно перенести в БД:
from django.db.models import F
Post.objects.filter(pk=post_id).update(views=F("views") + 1)Экземпляр post, если он уже загружен, после такого update содержит старое значение. При необходимости вызовите refresh_from_db().
save() и delete() — не глобальные hooksПереопределение save() влияет на instance.save(), но QuerySet.update(), bulk_create() и часть bulk-сценариев его обходят. То же относится к простому soft delete через Model.delete(): QuerySet.delete() требует отдельной реализации.
Поэтому soft delete — не двухстрочная функция. Нужно заранее решить:
Для базового курса безопаснее не показывать неполную реализацию как готовый паттерн.
makemigrations сравнивает модели с состоянием предыдущих миграций и создаёт Python-файл операций. migrate применяет их к базе.
python manage.py makemigrations blog
python manage.py sqlmigrate blog 0001
python manage.py migrateМиграция — часть исходного кода. Её коммитят и проверяют так же, как остальные изменения. На больших таблицах безопасная форма миграции зависит от конкретной СУБД и объёма данных.
NullBooleanField. Замена — BooleanField(null=True).Meta.unique_together. Для нового кода используйте UniqueConstraint.CheckConstraint(check=...). Используйте condition=...; он работает и в Django 5.2.DecimalField, а не float.null и blank.on_delete по бизнес-смыслу.save() универсальным hook.F().Далее: ORM: Базовые запросы