SQLModel в реальном проекте: миграции, индексы и как не потерять контроль над схемой
Соберем рабочую стратегию для SQLModel: от согласованного описания ограничений и индексов до миграций без сюрпризов и проверки, что модель действительно соответствует БД. Рассмотрим, как тестами защитить схему от регрессий.
Содержание
SQLModel в реальном проекте: миграции, индексы и как не потерять контроль над схемой
SQLModel обещает многое: типобезопасность, удобство Pydantic-моделей и знакомый синтаксис для работы с ORM. На практике же самая частая боль — не «как сделать модель», а «как сохранить контроль над схемой базы данных во времени». Особенно когда проект растёт, требования к целостности усложняются, индексы и ограничения начинают влиять на производительность, а миграции превращаются в источник сюрпризов.
Эта статья — про рабочую стратегию для SQLModel в реальном проекте: как согласованно описывать ограничения и индексы, как делать миграции без расхождений между моделями и БД, как проверять, что модель действительно соответствует реальности, и как закрепить всё тестами, чтобы регрессии не проходили незамеченными. В конце аккуратно упомянем курс SQLModel как практическое углубление в тему.
Почему «модель ≠ схема» — типичная проблема
ORM-модели часто становятся источником истины: разработчики думают, что «раз мы описали Field(nullable=False), то в БД так и будет». Но в реальности есть минимум три слоя, которые могут расходиться:
- Модель (SQLModel/Pydantic) — как вы описали доменную структуру и валидации.
- DDL в миграциях — что реально выполнится в базе.
- Фактическая схема — что есть после серии миграций, ручных правок, изменений в прошлом.
Если вы полагаетесь только на автогенерацию DDL или на «переустройство базы» при старте, вы получаете риск:
- миграции не создаются или выполняются частично,
- индексы/constraints не совпадают с ожиданиями,
- в коде всё выглядит корректно, но БД не защищает целостность,
- тесты не ловят расхождения.
Правильная стратегия — не отрицать ORM, а выстроить дисциплину: согласованное описание ограничений и индексов, миграции как единственный механизм изменения схемы, и регулярная проверка соответствия (plus тесты).
Базовая архитектура: кто является источником истины
Чтобы «не потерять контроль», определите простые правила:
Правило 1. Схема меняется только через миграции
SQLModel может генерировать описания, но итогом должны быть миграции (например, Alembic), которые фиксируют изменения схемы и их порядок.
Правило 2. Модель описывает намерение, миграции — исполнение
Модель задаёт смысл (типы, nullable, уникальности, индексы), но вы обязаны обеспечить, что миграции действительно применяют эти изменения.
Правило 3. Проверяйте соответствие между моделью и БД
Не один раз «вручную», а в пайплайне. Для этого удобно сделать автоматическую проверку:
- либо сравнение с инспекцией БД (introspection),
- либо «генерация DDL и дифф» в рамках тестов,
- либо хотя бы проверка критичных ограничений/индексов.
Правило 4. Индексы — это часть контракта
Индексы — не декоративные. Они должны быть названы, документированы и проверяемы так же, как ограничения.
Описываем ограничения и индексы в SQLModel без двусмысленностей
Начнём с того, как правильно формулировать constraints и индексы в SQLModel, чтобы они однозначно попадали в схему при миграциях.
Типовые поля и ограничения: nullable, default, unique
Пример минимальной сущности:
from typing import Optional
from sqlmodel import SQLModel, Field
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True, max_length=255)
is_active: bool = Field(default=True, nullable=False)
На уровне модели:
primary_key=True— первичный ключ.index=True— создаёт индекс.unique=True— уникальность.nullable=False— принуждает поле быть NOT NULL (часто можно вывести автоматически, но лучше фиксировать явно там, где важна защита данных).
Подводный камень: index=True без контроля имени
Во многих ORM/миграционных цепочках имя индекса генерируется автоматически и может меняться между версиями/провайдерами. В долгой перспективе это осложняет сравнение схемы и отладку.
Поэтому для индексов, которые вы хотите контролировать, лучше задавать имена явно (это делается через sa_column/Index/настройки метаданных — в зависимости от того, как вы интегрируете Alembic). В SQLModel часто используют __table_args__ для передачи SQLAlchemy-конструкций.
Явные индексы через __table_args__
Пример для индексирования по нескольким колонкам и управления именем:
from typing import Optional
from sqlmodel import SQLModel, Field
from sqlalchemy import Index
class Order(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(nullable=False)
status: str = Field(nullable=False, max_length=32)
__table_args__ = (
Index("ix_order_user_id_status", "user_id", "status"),
)
Зачем это нужно:
- имя индекса стабильно,
- вы можете на него ссылаться в миграциях и тестах,
- проще инспектировать схему в БД и сравнивать.
Чек-ограничения (CHECK) и перечисления
Если у вас есть доменные ограничения вроде статуса заказа — лучше фиксировать их на уровне БД.
Пример CHECK:
from typing import Optional
from sqlmodel import SQLModel, Field
from sqlalchemy import CheckConstraint
class Order(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
status: str = Field(nullable=False, max_length=32)
__table_args__ = (
CheckConstraint(
"status IN ('created', 'paid', 'shipped', 'cancelled')",
name="ck_order_status_allowed_values",
),
)
Почему это важно в реальном проекте:
- БД становится последним барьером целостности.
- код и API могут ошибаться (или меняться), но данные не попадут в неконсистентное состояние.
Миграции: дисциплина генерации и применение
Теперь — как «без сюрпризов» жить с миграциями, когда схема должна следовать модели, а миграции не должны ломать прод.
Выберите стратегию миграций: Alembic (почти стандарт)
В мире Python экосистемы вокруг SQLAlchemy Alembic — де-факто инструмент. SQLModel строится на SQLAlchemy, значит стандартный подход: Alembic читает метаданные SQLAlchemy и генерирует diff.
Ключевой момент: миграция должна быть воспроизводимой. Это достигается при:
- стабильных именах индексов/constraints,
- фиксированной версии/конфигурации движка,
- контроле моделей и метаданных.
Не полагайтесь на автогенерацию как на «магическую правду»
Авогенерация Alembic удобна, но не гарантирует правильность семантики. Вы должны:
- просматривать созданные миграции,
- понимать, что именно меняется (особенно для индексирования и уникальности),
- проверять, что миграция идемпотентна по смыслу (для повторного применения не всегда актуально, но как минимум — что она корректна).
Пример: создаём миграцию под уникальность и составной индекс
Допустим, вы добавляете модель:
from typing import Optional
from sqlmodel import SQLModel, Field
from sqlalchemy import Index
class Session(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(nullable=False)
token: str = Field(nullable=False, unique=True, max_length=512)
__table_args__ = (
Index("ix_session_user_id", "user_id"),
)
Сценарий миграции должен отразить:
- создание таблицы,
- уникальный constraint на
token, - индекс по
user_id.
Если вы видите, что автогенерация создала индекс с другим именем или создала отдельный уникальный индекс вместо constraint — это не обязательно плохо, но это должно быть осознанным выбором. Лучший подход — зафиксировать имена и типы объектов.
Как избежать расхождения: согласование метаданных и моделей
Самая коварная часть — когда миграции перестают соответствовать модели не потому, что вы ошиблись, а потому что метаданные, которые использует Alembic, отличаются от актуальных.
Проверьте, что Alembic видит правильные модели
Обычно в env.py Alembic импортирует модели и поднимает target_metadata. Проверьте:
- импортируются ли все
table=Trueмодели, - нет ли условной регистрации моделей по флагам окружения,
- метаданные не переопределяются несколькими сборками.
Пример (идея, а не точная копия под ваш проект):
# alembic/env.py (фрагмент)
from myapp.db.model import SQLModel
from myapp.models import User, Order, Session # важно: импортировать все модели
from sqlmodel import SQLModel
target_metadata = SQLModel.metadata
Если какая-то таблица не импортирована, Alembic может не видеть её метаданных — и дифф будет неполным.
Фиксируйте имена для индексных объектов
Имя:
- помогает в тестах,
- снижает риск «лишних миграций» при каждом запуске,
- упрощает миграции между dev/staging/prod.
Ориентир: индексы/constraints стоит называть явно, если они участвуют в производственном пути.
Когда индексы «переезжают»: частые причины проблем
Ниже — типовые причины, почему вы смотрите в модель, а в БД «не то».
1) Слишком «широкое» использование index=True
index=True удобно для простых случаев, но без имени вы усложняете контроль. Для критичных индексов лучше явно задать Index(...) с понятным именем.
2) Несовпадение типов и длины (VARCHAR/NVARCHAR, max_length)
БД строго различает некоторые параметры. Если в модели max_length=255, а в миграции получилась другая длина — это может быть:
- следствием неверной генерации схемы,
- следствием изменений на уровне базы/диалекта,
- различиями между PostgreSQL и SQLite (даже тестовые схемы могут не совпадать по типам).
3) Непредвиденное поведение nullable и default
Обычно nullable определяется из Optional[...]. Но в реальном коде много «почти optional» конструкций. Пример: вы ожидаете NOT NULL, но поле описано как Optional и помечено как допускающее NULL — и Alembic создаёт nullable-колонку.
Правило практики: если поле по домену обязателен — делайте его не-optional (убирайте Optional) и фиксируйте nullable=False там, где это критично.
4) Конфликты при изменении уникальности
Добавление unique=True на колонку в существующей таблице может упасть, если в данных уже есть дубликаты. Это не «техническая ошибка», а бизнес-проблема, которую миграции нужно пережить:
- заранее очистить данные,
- сделать миграцию в несколько шагов,
- использовать backfill, потом constraint.
Порядок миграций для схемы без сюрпризов: безопасные паттерны
Индекс и constraint — это не просто DDL. Это операция, которая может требовать lock/ресурсов и менять план выполнения запросов. Поэтому полезно иметь шаблоны.
Паттерн A: add nullable first → backfill → enforce NOT NULL
Когда вы добавляете новое поле, которое позже станет NOT NULL:
- Добавьте колонку nullable (или с default, если безопасно).
- Заполните значения на уровне SQL (backfill).
- Переведите в NOT NULL.
- (Опционально) добавьте индексы/constraints.
Это особенно важно для прод-миграций.
Паттерн B: уникальность — частями
Если в данных уже может быть дубликат:
- Создайте временный staging-столбец/или выполните дедупликацию.
- Потом добавляйте unique constraint.
- Затем удалите лишнее/приведите данные к новому формату.
Паттерн C: индексы без простоя (на PostgreSQL)
На PostgreSQL индексы можно создавать без длительного блокирования (в зависимости от типа индекса и версии). Но в SQLAlchemy/Alembic это всё равно нужно проверять: некоторые операции могут блокировать.
Если у вас большие таблицы — обязательно тестируйте на staging с данными близкими к продовым.
Проверка соответствия модели и БД: тесты, которые реально ловят регрессии
Теперь самое важное для «контроля»: тесты должны обнаруживать расхождения между ожидаемой схемой (из SQLModel) и фактической схемой в базе.
Есть два слоя тестирования:
- Unit/интеграционные тесты миграций — что после применения миграций схема соответствует ожиданиям.
- Проверка критичных индексов/constraints — что именно ожидаемое существует.
Практичный подход: инспекция схемы через SQLAlchemy Inspector
Пример: после поднятия тестовой базы применяем миграции, затем проверяем наличие индекса/constraint по имени.
Упрощённый каркас:
from sqlalchemy import create_engine, inspect
import pytest
@pytest.fixture(scope="function")
def engine():
# В реальном проекте используйте тестовый URL и транзакции/транкейт
engine = create_engine("postgresql+psycopg://user:pass@localhost/test_db")
return engine
def test_index_exists(engine):
inspector = inspect(engine)
indexes = inspector.get_indexes("orders")
index_names = {idx["name"] for idx in indexes}
assert "ix_order_user_id_status" in index_names
Что это даёт:
- тест проваливается, если миграции не создали индекс или имя изменилось,
- вы не полагаетесь на то, что ORM «сам всё сделает».
Проверка NOT NULL и UNIQUE
Для уникальности обычно проще инспектировать unique_constraints и unique_indexes:
def test_unique_constraint_exists(engine):
inspector = inspect(engine)
uniques = inspector.get_unique_constraints("users")
unique_names = {u["name"] for u in uniques}
assert "uq_users_email" in unique_names
Но важно: точное имя уникального constraint может отличаться в зависимости от того, как именно был сгенерирован constraint (SQLAlchemy может создавать unique index вместо constraint). Поэтому стратегия такая:
- либо вы явно задаёте name через
__table_args__, - либо в тесте проверяете наличие уникальности более гибко: по колонке.
Что с CHECK-constraints?
Проверка CHECK через Inspector тоже возможна, но зависит от диалекта. На PostgreSQL часто достаточно инспекции constraint name.
Если хочется сделать проверку более устойчивой, можно:
- искать constraints по тексту/шаблону,
- проверять только наличие constraint по имени.
Практика: если CHECK важен для целостности — давайте ему фиксированное имя и проверяйте его.
«Модель соответствует БД»: автоматизация в CI
Чтобы тесты стали частью процесса разработки, важно определить: где их запускать и что они проверяют.
Рекомендованный минимальный набор тестов
- Миграции от нуля: применить все миграции на чистой БД, затем проверить наличие таблиц, колонок, индексов/constraints.
- Миграции точечно: применить миграции до N, затем N+1 и проверить изменившиеся объекты.
- Регрессия на критичное: список «обязательных» индексов/constraints как контракт.
Даже если тесты не покрывают весь спектр схемы, они защищают от самых дорогих ошибок: пропущенная миграция, переименованный индекс, потерянный constraint.
Не делайте тесты слишком хрупкими
Если тесты сравнивают каждую мелочь, любая допустимая оптимизация (например, имя индекса, параметр хранения) будет ломать сборку. Поэтому:
- фиксируйте имена для критичных объектов,
- остальное проверяйте на уровне смысла (например, что поле NOT NULL, что уникальность по колонке существует).
Стратегия ведения схемы как кода
С учётом всего выше, «не потерять контроль» — это не один трюк, а набор дисциплин.
1) Соглашение об именах
Заведите правила вроде:
- индексы:
ix_<table>_<columns>, - unique constraints:
uq_<table>_<column>, - check constraints:
ck_<table>_<purpose>.
И придерживайтесь их во всех моделях.
2) Список критичных constraints
Соберите в одном месте (возможно, отдельный модуль) список:
- какие ограничения/индексы обязательны,
- на каких таблицах,
- каковы имена.
Тесты будут использовать этот список.
3) Review миграций как часть code review
Миграции не менее важны, чем изменения в бизнес-логике:
- проверяйте, что DDL ожидаем,
- учитывайте влияние на прод (locks, объём таблиц),
- смотрите на последствия для запросов (особенно при добавлении индексов и unique).
Комментарии
Пока нет комментариев