Связи таблиц в SQLAlchemy ORM
Проектирование One-to-Many, решение проблемы N+1 и продвинутая агрегация.
Содержание курса
1. Настройка и Проектирование
1.1 Структура проекта
В серьезных приложениях модели не хранят в одном файле. Мы разделим их логически, чтобы избежать путаницы и циклических импортов.
project/
├── models/
│ ├── __init__.py # Экспорт всех моделей
│ ├── base.py # Базовый класс (DeclarativeBase)
│ ├── user.py # Модель пользователя
│ └── post.py # Модель поста
├── main.py # Запуск кода
└── docker-compose.yml # База данных
1.2 Описание моделей (One-to-Many)
Рассмотрим классическую связь: Один пользователь может написать Много постов.
Для этого нам понадобятся два инструмента:
ForeignKey(в базе данных) — физическое ограничение, ссылка на ID другой таблицы.relationship(в Python) — "магическая" связь, позволяющая обращаться к связанным объектам как к атрибутам (например,user.posts).
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(32), nullable=False, unique=True)
email = Column(String, nullable=True, unique=True)
# Указываем, что у юзера есть список постов.
# back_populates="author" связывает это поле с полем author в модели Post
posts = relationship("Post", back_populates="author")
def __repr__(self):
return f"User(id={self.id}, username='{self.username}')"
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from .base import Base
class Post(Base):
__tablename__ = "posts"
id = Column(Integer, primary_key=True)
title = Column(String(100), nullable=False)
# server_default=func.now() заставляет БД (Postgres) самой ставить время при вставке
published_at = Column(DateTime, nullable=False, server_default=func.now())
# ФИЗИЧЕСКАЯ СВЯЗЬ: колонка user_id ссылается на users.id
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# ЛОГИЧЕСКАЯ СВЯЗЬ: позволяет получить объект User через post.author
author = relationship("User", back_populates="posts")
Почему back_populates? Раньше использовали backref, который неявно создавал поле в другой модели. Современный стиль SQLAlchemy требует явного определения полей в обоих классах с back_populates. Это делает код читаемым и понятным для IDE.
2. Проблема N+1 и Стратегии загрузки
2.1 Что такое N+1?
Это самая частая проблема производительности в ORM. Допустим, мы хотим вывести список заголовков постов и имена их авторов.
Плохой подход ❌
# 1. Делаем запрос на получение всех постов (1 запрос)
posts = session.scalars(select(Post)).all()
for post in posts:
# 2. При обращении к post.author SQLAlchemy делает "ленивый" запрос в БД
print(f"{post.title} написал {post.author.username}")
Если у вас 100 постов, будет выполнен 1 запрос для списка + 100 запросов для каждого автора. Итого 101 запрос.
2.2 Решение: Оптимизация запросов
Нужно сказать SQLAlchemy: "Загрузи связанные данные сразу". Для этого есть две основные стратегии.
joinedload
Использует SQL JOIN.
Когда использовать: Связь "Многие-к-Одному" (Post -> Author). Загружает данные в том же запросе.
stmt = select(Post).options(
joinedload(Post.author)
)
posts = session.scalars(stmt).all()
selectinload
Использует второй запрос с WHERE IN (...).
Когда использовать: Связь "Один-ко-Многим" (User -> Posts). Избегает декартова произведения (дублирования строк).
stmt = select(User).options(
selectinload(User.posts)
)
users = session.scalars(stmt).all()
Что происходит под капотом при selectinload?
SELECT * FROM users;
-- Запрос 2: Получаем посты для этих юзеров
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
3. Аналитика: Фильтрация и Агрегация
3.1 Фильтрация через JOIN
Часто нужно найти родителя по свойствам ребенка (или наоборот). Например: "Найти всех пользователей, которые написали пост со словом 'Docker'".
Для этого мы явно соединяем таблицы через .join().
stmt = (
select(User)
.join(User.posts) # Присоединяем таблицу постов
.where(Post.title.ilike("%docker%")) # Фильтруем по полю поста
.options(selectinload(User.posts)) # Не забываем подгрузить посты для вывода
)
users = session.scalars(stmt).all()
3.2 Группировка (GROUP BY) и HAVING
SQLAlchemy позволяет строить сложные аналитические запросы. Посчитаем, сколько постов у каждого пользователя.
from sqlalchemy import func
stmt = (
# Выбираем ID, Имя и Количество постов
select(User.id, User.username, func.count(Post.id).label("total_posts"))
.join(User.posts, isouter=True) # LEFT JOIN, чтобы учесть юзеров без постов
.group_by(User.id, User.username) # Группируем по уникальному юзеру
.order_by(User.id)
)
# Результат — это список кортежей (не объектов моделей!)
results = session.execute(stmt).all()
for user_id, name, count in results:
print(f"{name}: {count} постов")
Фильтрация после группировки (HAVING)
Если нам нужны только "активные" авторы (например, более 1 поста), мы не можем использовать where(), так как количество еще не посчитано. Используем having().
stmt = (
select(User)
.join(User.posts)
.group_by(User.id)
.having(func.count(Post.id) > 1) # Оставляем только тех, у кого > 1 поста
.options(selectinload(User.posts))
)
active_users = session.scalars(stmt).all()
Запомните: WHERE фильтрует исходные строки ДО группировки. HAVING фильтрует результаты агрегатных функций ПОСЛЕ группировки.
Резюме
Мы научились связывать таблицы через ForeignKey и relationship. Главное правило производительности: всегда думайте о том, как и когда данные загружаются из БД. Используйте joinedload для одиночных связей и selectinload для коллекций.