Модель это договор между классом Python и таблицей в базе. Если договор составлен небрежно, расхождение всплывает не сразу: деньги теряют копейки во float, время без часового пояса сдвигается при переезде сервера, ограничение существует только в голове разработчика. Разберём, как описывать модели так, чтобы схема, типы и правила были в одном месте и совпадали с базой.
Класс, таблица, колонка
Все модели наследуют один базовый класс, а каждое поле объявляют аннотацией Mapped[...]:
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, Numeric, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "product"
id: Mapped[int] = mapped_column(primary_key=True)
sku: Mapped[str] = mapped_column(String(32), unique=True)
name: Mapped[str] = mapped_column(String(200))
price: Mapped[Decimal] = mapped_column(Numeric(12, 2))
description: Mapped[str | None]
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
Три правила чтения. Тип колонки SQLAlchemy выводит из аннотации: int станет INTEGER, str без длины VARCHAR, Decimal NUMERIC; когда нужно уточнить (длина, точность, часовой пояс), тип передают в mapped_column. Обязательность тоже из аннотации: Mapped[str] это NOT NULL, Mapped[str | None] допускает NULL. А mapped_column() без аргументов можно вовсе опустить, как у description: аннотации достаточно.
Base одна на сервис, и у неё есть Base.metadata со всеми таблицами; по ней Alembic строит миграции, а тесты создают схему.
Типы, на которых ошибаются
Деньги. Mapped[float] для цены это ошибка: 0.1 + 0.2 во float не равно 0.3, и округление накапливается. Деньги хранят в Numeric(12, 2) и читают как Decimal, либо целым числом копеек в BigInteger. Статья про числа в PostgreSQL объясняет, почему именно так.
Время. DateTime() без аргументов это timestamp without time zone: база хранит число без пояса, и значение зависит от настроек сервера. Правило одно: DateTime(timezone=True) везде, а в Python только aware-значения, datetime.now(UTC). Чтобы не повторять это в каждой колонке, тип привязывают к аннотации один раз:
class Base(DeclarativeBase):
type_annotation_map = {
datetime: DateTime(timezone=True),
Decimal: Numeric(12, 2),
}
После этого created_at: Mapped[datetime] без mapped_column даёт колонку с часовым поясом.
Перечисления. StrEnum в Python и две стратегии в базе: sa.Enum(OrderStatus, native_enum=False, length=32) хранит строку в VARCHAR, и новое значение добавляют правкой кода без миграции; CHECK-ограничение на список значений появится только с create_constraint=True, по умолчанию его нет. Нативный ENUM PostgreSQL компактнее, но добавление значения это ALTER TYPE, а удаление невозможно без пересоздания типа. Для статусов, которые меняются, берут первый вариант.
JSON. JSONB из sqlalchemy.dialects.postgresql для документов и настроек; изменение словаря на месте ORM не замечает, об этом в статье про грабли.
Идентификаторы. Mapped[int] = mapped_column(primary_key=True) даёт автоинкремент (в PostgreSQL по умолчанию SERIAL; современный IDENTITY включают явно через mapped_column(sa.Identity(), primary_key=True)), Mapped[uuid.UUID] = mapped_column(sa.Uuid, primary_key=True, default=uuid.uuid7) даёт UUID, который генерирует приложение; почему uuid7, а не uuid4, объясняет статья про адреса ресурсов.
Умолчание в Python и умолчание в базе
У mapped_column два разных параметра, и путать их дорого. default= подставляет значение SQLAlchemy при INSERT, если атрибут не задан: это может быть константа или функция, которую зовут на стороне Python (default=uuid.uuid7). Оно не существует для базы: INSERT из миграции или из другого сервиса его не увидит. server_default= попадает в CREATE TABLE как DEFAULT now() и работает для всех, кто пишет в таблицу, но значение становится известно объекту только после перечитывания из базы.
Правило: то, что должно быть истинным для таблицы (время создания, флаг по умолчанию), объявляют server_default; то, что нужно знать в Python до записи (идентификатор, который вернут клиенту), объявляют default. Для времени записи берут server_default=func.now(): часы базы одни на всех, а часы подов могут расходиться.
Ограничения и индексы
Уникальность одной колонки это unique=True, индекс по одной колонке index=True. Всё составное живёт в __table_args__:
from sqlalchemy import CheckConstraint, Index, UniqueConstraint
class OrderItem(Base):
__tablename__ = "order_item"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE"))
product_id: Mapped[int] = mapped_column(ForeignKey("product.id"))
quantity: Mapped[int]
__table_args__ = (
UniqueConstraint("order_id", "product_id", name="uq_order_item_order_product"),
CheckConstraint("quantity > 0", name="ck_order_item_quantity_positive"),
Index("ix_order_item_product", "product_id"),
)
Ограничения в базе не заменяют проверок в агрегате, а страхуют их: правило «количество больше нуля» живёт в методе Order.add_line, а CHECK ловит обходной путь через миграцию или ручной SQL.
Имена ограничениям дают всегда. Безымянное ограничение PostgreSQL назовёт сам, и через год миграция, которой нужно его удалить, не знает имени. Чтобы не придумывать имена руками, базе задают соглашение:
from sqlalchemy import MetaData
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)
С ним Alembic генерирует предсказуемые имена, и drop_constraint в миграции перестаёт быть угадайкой.
Модель не равна схеме запроса
Соблазн использовать класс модели как схему ответа FastAPI понятен: поля уже описаны. Но модель это форма хранения, а ответ это контракт API, и они расходятся с первого же поля, которого нельзя отдавать наружу. Ответ собирают Pydantic-моделью с from_attributes=True, как в статье про валидацию, а модель SQLAlchemy остаётся внутри репозитория. В гексагональной раскладке она и вовсе лежит в адаптере, а домен описан отдельным классом, об этом статья про слой core.
Глубже: наследование: одна таблица или несколькорасширенное
Иногда у сущности есть виды с разными полями: платёж картой с маскированным номером, платёж по СБП с идентификатором операции. SQLAlchemy отображает иерархию классов на таблицы двумя способами.
Одна таблица (single table inheritance): все поля всех видов в одной таблице, колонка-дискриминатор говорит, какой класс создавать. Объявляется через __mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "card"} у подклассов. Дёшево в запросах (без соединений), но колонки чужих видов пустуют, и NOT NULL на них поставить нельзя.
Таблица на подкласс (joined table inheritance): общая таблица payment и по таблице на вид с внешним ключом на общую. Схема честнее, ограничения ставятся, но каждая загрузка это соединение, а список платежей всех видов собирается несколькими.
Правило выбора: пока видов два-три и различающихся полей мало, одна таблица; когда у видов свои ограничения и свои индексы, таблица на подкласс. И третий вариант, о котором забывают: поле JSONB с деталями вида и без наследования вовсе, когда по этим полям не фильтруют.
Коротко
- Модель объявляют через
Mapped[...]: тип и обязательность берутся из аннотации, уточнения (String(200),Numeric(12, 2),DateTime(timezone=True)) передают вmapped_column. - Деньги в
Numericили целых копейках, время только с часовым поясом; повторяющиеся типы закрепляют вtype_annotation_mapбазового класса. - Перечисления для меняющихся статусов хранят строкой с
native_enum=False(CHECKтолько сcreate_constraint=True), нативныйENUMPostgreSQL дорог в изменении. defaultподставляет SQLAlchemy при вставке из Python,server_defaultживёт в схеме и работает для всех, кто пишет в таблицу; время создания этоserver_default=func.now().- Составные ограничения и индексы в
__table_args__, имена черезnaming_conventionуMetaData, иначе миграции не знают, что удалять. - Модель это форма хранения, не схема API и не доменный класс; наружу идёт Pydantic с
from_attributes. - Наследование: одна таблица с дискриминатором, пока видов мало; таблица на подкласс, когда у видов свои ограничения.
Что почитать дальше
- Связи между таблицами —
relationship, внешние ключи, каскады. - Миграции: Alembic — как изменения моделей превращаются в миграции и почему автогенерация это черновик.
- Дата и время в PostgreSQL — что стоит за
timestamptzи почему время без пояса ломается при переезде.