SQLAlchemy требует unique после joinedload: что произошло со строками
После замены ленивой загрузки на joinedload запрос начал падать с требованием вызвать unique. Выборка вроде бы обычная: список проектов вместе с задачами. Однако на уровне SQL один проект повторяется в нескольких строках, потому что каждая строка соединена со своей задачей. ORM должен собрать эти строки обратно в один объект с коллекцией.
Что именно устраняет unique
У проекта с двумя задачами SQL-соединение даёт две строки с одинаковым ключом проекта. scalars выбирает ORM-сущность из результата, но само по себе не задаёт явное устранение таких повторов. Для joinedload коллекции SQLAlchemy требует unique, чтобы решение уменьшить число возвращаемых объектов было видимым в коде. Оно выполняется по идентичности ORM-объекта, а не по случайному совпадению отображаемых полей.
Вызов distinct в SQL не заменяет этот шаг: строки с разными дочерними колонками действительно различаются. Более того, DISTINCT меняет SQL и может повлиять на план. Сначала определите, что должен вернуть Python-код: список проектов, плоские пары проект-задача или агрегат. Для каждого результата нужен свой запрос. Здесь нужен один объект проекта с полной коллекцией задач.
Воспроизведём отказ и правильную сборку
Установите SQLAlchemy; пример рассчитан на современный стиль select и Session. Он создаёт только временную SQLite в памяти. Проверим проект с двумя задачами и проект без задач: это одновременно проверяет сборку коллекции и сохранение родителя без дочерних записей.
from sqlalchemy import create_engine, ForeignKey, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session
class Base(DeclarativeBase):
pass
class Project(Base):
__tablename__ = "projects"
id: Mapped[int] = mapped_column(primary_key=True)
tasks: Mapped[list["Task"]] = relationship()
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"))
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
db.add_all([Project(id=1, tasks=[Task(id=10), Task(id=11)]), Project(id=2)])
db.commit()
from sqlalchemy.orm import joinedload
from sqlalchemy.exc import InvalidRequestError
stmt = select(Project).options(joinedload(Project.tasks)).order_by(Project.id)
with Session(engine) as db:
try:
db.scalars(stmt).all()
except InvalidRequestError as exc:
assert "unique" in str(exc)
print({"without_unique": type(exc).__name__})
else:
raise AssertionError("collection joinedload must require unique")
with Session(engine) as db:
projects = db.scalars(stmt).unique().all()
result = [{"id": p.id, "tasks": sorted(t.id for t in p.tasks)} for p in projects]
assert result == [{"id": 1, "tasks": [10, 11]}, {"id": 2, "tasks": []}]
print(result)
engine.dispose()
Почему здесь остаётся пустой проект
По умолчанию joinedload использует внешнее соединение. Поэтому проект без задач не исчезает из списка, а получает пустую коллекцию. Переключение innerjoin=True допустимо, только если именно такой смысл нужен выборке или связь гарантированно существует. Иначе исправление одной ошибки загрузки незаметно изменит состав результата.
Для коллекции из многих строк оцените объём передаваемых колонок. Один SQL-запрос не всегда дешевле двух: поля родителя повторяются на каждой дочерней строке. Для обычного списка с несколькими коллекциями selectinload часто проще. Но когда joinedload уже выбран по характеру данных, требование unique следует выполнить, а не обходить ручным set после получения результата.
В HTTP-приложении собирайте ответ, пока Session открыта, и заранее определяйте необходимые отношения. Возвращение ORM-объекта без понимания загрузки может запустить SQL позже при сериализации. Тест настоящего endpoint полезно дополнить этим маленьким тестом запроса: тогда понятно, нарушилась сборка данных или их внешнее представление.
Если список пуст, unique всё равно корректен и возвращает пустой результат. Добавьте такой случай к тестам репозитория вместе с несколькими задачами и отсутствием задач. Одна запись с одной дочерней строкой скрывает размножение результата и легко создаёт ложную уверенность в исправлении.
Ближайшая задача загрузки коллекций разобрана в материале об N+1. Там цель — сократить неожиданные запросы. Здесь исправляется другой договор: превращение строк JOIN в уникальные объекты родителя.
Продолжить на практике
Постройте список сущностей с отношением и проверьте его через API. В бесплатной программе FastAPI есть SQLAlchemy, CRUD и тесты: они дадут основу для воспроизводимого запроса и проверки JSON. Откройте бесплатную программу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
