Сложные связи в базах данных с использованием SQLAlchemy
Руководство по отношениям Many-to-Many, оптимизации запросов и решению проблемы N+1.
Навигация по уроку
1. Теория и Проблематика
Проблема N+1 запросов
Прежде чем писать код, важно понять главную "боль" работы с БД. Представьте, что вы загружаете список из 10 пользователей, и хотите показать их последние посты.
По умолчанию ORM работает "лениво". Сначала она делает 1 запрос, чтобы получить список юзеров. Затем, когда вы в цикле обращаетесь к user.posts, она делает еще по 1 запросу для каждого юзера.
Итог: 1 (юзеры) + 10 (посты) = 11 запросов. Если юзеров будет 1000, приложение "ляжет". Это и есть проблема N+1.
Связи один-ко-многим (One-to-Many)
Это самая частая связь. Один автор может написать много постов. В базе данных это решается просто: в таблицу posts добавляется колонка user_id (Foreign Key).
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
posts = relationship("Post", back_populates="user")
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
# Ссылка на таблицу users
user_id = Column(Integer, ForeignKey("users.id"))
# Объектная связь для удобства работы в Python
user = relationship("User", back_populates="posts")
Параметр back_populates "связывает" атрибуты двух классов. Если вы добавите пост в user.posts, то у этого поста автоматически заполнится поле post.user.
2. Реализация Many-to-Many
Связь "Многие-ко-Многим" сложнее. Пример: Пост может иметь много Тегов, а один Тег может висеть на множестве постов. Напрямую связать две таблицы нельзя. Нам нужен посредник — Ассоциативная таблица (Association Table).
Шаг 1. Создание таблицы-посредника
Мы создаем таблицу, которая хранит только пары ID: post_id и tag_id. Обычно для неё не нужен отдельный класс-модель, достаточно объекта Table.
# models/association.py
from sqlalchemy import Table, Column, Integer, ForeignKey
from .database import Base
post_tag_association = Table(
"post_tag_association",
Base.metadata,
Column("post_id", Integer, ForeignKey("posts.id"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True)
)
Обратите внимание: мы делаем оба поля primary_key=True. Это создает составной первичный ключ, гарантируя, что один и тот же тег не привяжется к одному посту дважды.
Шаг 2. Настройка моделей
Теперь нужно "научить" модели Post и Tag использовать эту таблицу. Для этого в relationship добавляется параметр secondary.
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String)
# Указываем таблицу-посредника через secondary
tags = relationship(
"Tag",
secondary=post_tag_association,
back_populates="posts"
)
class Tag(Base):
__tablename__ = "tags"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True)
# Зеркальная связь
posts = relationship(
"Post",
secondary=post_tag_association,
back_populates="tags"
)
Шаг 3. Нюансы миграций (Alembic)
Осторожно с импортами!
Частая ошибка: вы создали таблицу post_tag_association, но Alembic создает пустую миграцию. Почему?
Alembic видит только те модели, которые импортированы при запуске. Если ваша ассоциативная таблица лежит в отдельном файле и нигде не импортируется в env.py, она будет проигнорирована.
Решение: Соберите все модели в models/__init__.py:
# models/__init__.py
from .user import User
from .post import Post
from .tag import Tag
# Обязательно импортируем ассоциативную таблицу!
from .association import post_tag_association
3. Оптимизация запросов
Чтобы избежать проблемы N+1, мы должны явно сказать SQLAlchemy: "Загрузи связанные данные сразу". Для этого есть разные стратегии.
Стратегии загрузки: joinedload vs selectinload
joinedload
Делает один огромный запрос с LEFT JOIN.
Где использовать: Идеально для связей Many-to-One (пост -> автор). Для списков может дублировать данные.
selectinload (Рекомендуется)
Делает два запроса: первый получает посты, второй — теги через WHERE post_id IN (...).
Где использовать: Идеально для коллекций One-to-Many и Many-to-Many.
Пример правильного запроса
Допустим, нам нужно получить список постов, их авторов и теги. Мы комбинируем стратегии: автора подтягиваем через JOIN, а теги — отдельным запросом.
from sqlalchemy.orm import selectinload, joinedload
def get_posts_optimized(session):
stmt = (
select(Post)
.options(
joinedload(Post.user), # Автор загрузится сразу через JOIN
selectinload(Post.tags) # Теги загрузятся вторым эффективным запросом
)
)
# Итог: всего 2 SQL-запроса на любой объем данных
return session.scalars(stmt).all()
4. Практика и Best Practices
Работа с данными
SQLAlchemy позволяет работать со связями как с обычными списками Python. Добавление тега к посту выглядит очень естественно.
def add_tag_to_post(session, post_id, tag_name):
post = session.get(Post, post_id)
# Пытаемся найти тег или создаем новый
tag = session.scalar(select(Tag).where(Tag.name == tag_name))
if not tag:
tag = Tag(name=tag_name)
# МАГИЯ ORM: просто добавляем в список
post.tags.append(tag)
# SQLAlchemy сама сгенерирует INSERT в таблицу post_tag_association
session.commit()
Транзакции и атомарность
Одна из самых больших ошибок новичков — делать commit() внутри вспомогательных функций. Коммит должен быть один, в самом конце бизнес-операции.
Антипаттерн ❌
def create_post(data):
post = Post(...)
session.add(post)
session.commit() # ПЛОХО: пост создался, а теги еще нет
def add_tags(post, tags):
...
session.commit()
Правильный подход: функция выполняет всю логику (создание поста + привязка тегов), и только если всё прошло успешно, фиксирует транзакцию.
def create_full_post(session, title, tag_names):
try:
# 1. Создаем пост
post = Post(title=title)
session.add(post)
# 2. Привязываем теги (в памяти)
for name in tag_names:
tag = Tag(name=name)
post.tags.append(tag)
# 3. Фиксируем ВСЁ сразу
session.commit()
except Exception:
session.rollback()
raise
Заключение
Используйте selectinload для коллекций (M2M), чтобы избежать тормозов.
Для M2M связей всегда создавайте явную ассоциативную таблицу (или модель).
Проверяйте импорты в env.py, если Alembic не видит изменений.