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

В базе связь это внешний ключ: колонка customer_id в таблице заказов. В объектах связь это атрибут: order.customer и customer.orders. SQLAlchemy соединяет одно с другим через relationship, и большая часть удобства ORM живёт здесь же, как и большая часть сюрпризов: удаление, которое удалило лишнее, и список, который загрузился на миллион строк.

Обязательно

Один-ко-многим

Внешний ключ объявляют на стороне «многих», а relationship с обеих сторон, связав их через back_populates:

from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship

class Customer(Base):
    __tablename__ = "customer"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    orders: Mapped[list["Order"]] = relationship(back_populates="customer")

class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"))
    customer: Mapped["Customer"] = relationship(back_populates="orders")

Mapped[list["Order"]] говорит SQLAlchemy, что это коллекция, Mapped["Customer"] что одиночная ссылка; направление и колонку соединения ORM выводит из ForeignKey. back_populates держит обе стороны согласованными в памяти: после customer.orders.append(order) атрибут order.customer уже указывает на клиента без обращения к базе, и наоборот.

В старом коде встречается backref, который объявляет обе стороны из одного места. Он работает, но вторая сторона появляется неявно, и анализатор типов её не видит; новый код пишут через back_populates с обеих сторон.

Многие-ко-многим

Товар входит в несколько категорий, у категории много товаров. В базе это таблица связи из двух внешних ключей, в SQLAlchemy параметр secondary:

from sqlalchemy import Column, Table

product_category = Table(
    "product_category",
    Base.metadata,
    Column("product_id", ForeignKey("product.id", ondelete="CASCADE"), primary_key=True),
    Column("category_id", ForeignKey("category.id", ondelete="CASCADE"), primary_key=True),
)

class Product(Base):
    __tablename__ = "product"
    id: Mapped[int] = mapped_column(primary_key=True)
    categories: Mapped[list["Category"]] = relationship(secondary=product_category, back_populates="products")

class Category(Base):
    __tablename__ = "category"
    id: Mapped[int] = mapped_column(primary_key=True)
    products: Mapped[list["Product"]] = relationship(secondary=product_category, back_populates="categories")

Таблицу связи здесь описывают как Table, а не как класс: у неё нет собственной жизни. Как только у связи появляются свои данные (дата добавления, кто добавил, порядок), она становится сущностью: класс ProductCategory с двумя внешними ключами и полями, и две обычные связи один-ко-многим к нему. Это называют объектом связи, и переход на него лучше делать сразу, как только появилось первое поле: переделывать secondary в класс на живых данных неприятно.

Каскады: что происходит с детьми

Удалили клиента. Что станет с его заказами? У SQLAlchemy на этот вопрос отвечает параметр cascade у relationship, и значения стоит знать точно.

По умолчанию каскад save-update, merge: добавили заказ в customer.orders, и session.add(customer) подхватит и заказ. Удаление детей по умолчанию не каскадируется: при session.delete(customer) SQLAlchemy попробует обнулить customer_id у заказов, и если колонка NOT NULL, упадёт на IntegrityError.

cascade="all, delete-orphan" на стороне родителя означает две вещи: удаление родителя удаляет детей, и ребёнок, вынутый из коллекции (customer.orders.remove(order)), удаляется тоже, потому что без родителя он сирота. Это правильный выбор для частей агрегата: позиции заказа без заказа не существуют.

class Order(Base):
    items: Mapped[list["OrderItem"]] = relationship(
        back_populates="order", cascade="all, delete-orphan", passive_deletes=True
    )

passive_deletes=True в паре с ondelete="CASCADE" на внешнем ключе отдаёт удаление детей базе: SQLAlchemy не грузит коллекцию, чтобы удалить каждого ребёнка отдельным DELETE, а выполняет один DELETE родителя и верит, что база сделает остальное. На заказе с тысячей позиций разница между одним запросом и тысячей.

Чего каскад не делает: не защищает от удаления того, на что ссылаются другие агрегаты. Клиент с заказами не удаляется, и это должно быть ошибкой домена («у клиента есть заказы»), а не IntegrityError из глубины репозитория.

Порядок, фильтр и ссылка на себя

Коллекции по умолчанию без порядка; relationship(order_by="OrderItem.position") даёт стабильный порядок при каждой загрузке. Фильтр в связи (primaryjoin с условием «только активные») возможен, но превращает атрибут в ловушку: customer.orders показывает не все заказы, и об этом никто не помнит; фильтры лучше держать в запросах.

Ссылка на себя (дерево категорий) объявляется с remote_side:

class Category(Base):
    parent_id: Mapped[int | None] = mapped_column(ForeignKey("category.id"))
    parent: Mapped["Category | None"] = relationship(back_populates="children", remote_side="Category.id")
    children: Mapped[list["Category"]] = relationship(back_populates="parent")

Загрузка дерева целиком через children это рекурсия запросов; для глубоких деревьев берут рекурсивный CTE через Core, о чём статья про запросы.

Связь и агрегат

С точки зрения DDD не каждая связь в базе должна становиться relationship. Внутри агрегата (заказ и его позиции) связь нужна: позиции загружаются и сохраняются вместе с заказом. Между агрегатами (заказ и клиент) достаточно колонки customer_id без relationship: заказ хранит идентификатор, а клиента при необходимости загружает свой репозиторий. Так агрегат не тянет за собой пол-базы при загрузке и не меняет чужое состояние через order.customer.name = .... Подробнее о границах в статье про тактические паттерны.

Дополнительно: при первом чтении можно пропустить

Глубже: взаимная ссылка в типах и ленивые аннотациирасширенное

Классы ссылаются друг на друга, и Mapped["Order"] в кавычках нужен, когда Order объявлен ниже по файлу или в другом модуле. SQLAlchemy разрешает строку по имени класса в реестре Base, поэтому имена классов в одном Base должны быть уникальными на весь сервис, а не на модуль: два класса Item в разных пакетах с общей Base дадут ошибку разрешения связи.

Когда модели разложены по модулям, импортируют их так, чтобы к моменту первого запроса все классы были зарегистрированы: обычно пакет models/__init__.py импортирует все модули, а приложение импортирует пакет. Иначе relationship на класс, который ещё не импортирован, падает при первом обращении с сообщением о неразрешённом имени.

Циклический импорт между модулями моделей решают теми же строковыми аннотациями и TYPE_CHECKING: для анализатора типов класс импортируют под if TYPE_CHECKING:, а SQLAlchemy хватает строки. С Python 3.14 аннотации вычисляются лениво, и кавычки для анализатора больше не нужны, но SQLAlchemy читает их своим способом, так что привычка писать имя строкой пока ничего не портит.

Коротко

  • Внешний ключ на стороне «многих», relationship с обеих сторон через back_populates; backref это старый неявный вариант.
  • Многие-ко-многим через Table и secondary; как только у связи появились свои поля, она становится классом с двумя связями один-ко-многим.
  • Каскад по умолчанию не удаляет детей; cascade="all, delete-orphan" для частей агрегата, passive_deletes=True с ondelete="CASCADE" отдаёт удаление базе одним запросом.
  • Удаление того, на что ссылаются чужие агрегаты, это ошибка домена, а не каскад.
  • order_by в связи даёт стабильный порядок коллекции, фильтры в связи делают атрибут обманчивым; ссылка на себя через remote_side.
  • Между агрегатами достаточно колонки с идентификатором без relationship; связи нужны внутри агрегата.

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