← назад к разделу

Модель это договор между классом 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), нативный ENUM PostgreSQL дорог в изменении.
  • default подставляет SQLAlchemy при вставке из Python, server_default живёт в схеме и работает для всех, кто пишет в таблицу; время создания это server_default=func.now().
  • Составные ограничения и индексы в __table_args__, имена через naming_convention у MetaData, иначе миграции не знают, что удалять.
  • Модель это форма хранения, не схема API и не доменный класс; наружу идёт Pydantic с from_attributes.
  • Наследование: одна таблица с дискриминатором, пока видов мало; таблица на подкласс, когда у видов свои ограничения.

Что почитать дальше