JOIN отфильтровал строки, а коллекция полная: contains_eager SQLAlchemy
Экран показывает проекты с активными задачами. Запрос соединяет Project и Task, фильтрует archived=False, а код затем читает project.tasks. В ответ снова попадают архивные задачи. Фильтр JOIN выбрал родителей по дочерним строкам, но сам по себе не сказал ORM заполнить коллекцию этими строками.
Связь между JOIN и отношением нужно указать
Обычный select(Project).join(Project.tasks) ограничивает набор проектов. Когда tasks остаётся незагруженным, последующий доступ запускает отдельный запрос по стандартному отношению и получает все задачи проекта. contains_eager сообщает, что уже присутствующие в SQL строки предназначены для загрузки конкретного отношения. Тогда выбранные активные задачи становятся содержимым коллекции.
Это удобно для специально подготовленного представления, но требует ясного смысла: коллекция теперь неполная. Код, считающий все задачи проекта, не должен принимать её за полный список. Для независимого экранного ответа иногда проще вернуть плоские строки или отдельный DTO с названием active_tasks. При выборе contains_eager следите за временем жизни таких объектов.
Сначала воспроизведём лишнюю задачу
Понадобится SQLAlchemy. Каждый сценарий использует новую Session. Сначала JOIN оставляет коллекцию стандартной и в ней есть архивная задача. Затем contains_eager заполняет её только активной строкой. unique нужен из-за возможного размножения родителя при соединении с коллекцией.
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"))
archived: Mapped[bool] = mapped_column(default=False)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
db.add_all([Project(id=1, tasks=[Task(id=10, archived=False),
Task(id=11, archived=True)]), Project(id=2)])
db.commit()
from sqlalchemy.orm import contains_eager
base = select(Project).join(Project.tasks).where(Task.archived.is_(False))
with Session(engine) as db:
wrong = db.scalars(base).first()
assert sorted(t.id for t in wrong.tasks) == [10, 11]
print({"plain_join_tasks": sorted(t.id for t in wrong.tasks)})
with Session(engine) as db:
projects = db.scalars(base.options(contains_eager(Project.tasks))).unique().all()
assert len(projects) == 1
assert [t.id for t in projects[0].tasks] == [10]
print({"filtered_tasks": [t.id for t in projects[0].tasks]})
db.expire(projects[0], ["tasks"])
assert sorted(t.id for t in projects[0].tasks) == [10, 11]
print({"after_expire": sorted(t.id for t in projects[0].tasks)})
engine.dispose()
Уже загруженная коллекция не заменяется автоматически
Если объект раньше получил полный список tasks в той же Session, новый запрос может вернуть прежнее содержимое. Для намеренного обновления используют populate_existing=True, предварительно определившись с локальными изменениями. В экранном read-сценарии короткая отдельная Session часто делает поведение проще: загружается ровно одно представление данных, затем строится ответ.
Фильтрованная коллекция не становится постоянным новым определением relationship. После expire или окончания транзакции она может загрузиться обычным способом и снова содержать все строки. В примере это проверяется явно. Не сохраняйте такой объект в долгоживущий кэш как носитель обещания «только активные задачи». Кэшировать лучше готовое представление с отдельным именем поля.
Если должны присутствовать проекты без активных задач, нужен внешний JOIN и условие активности в ON. Условие Task.archived=False в WHERE исключит строки с NULL и фактически уберёт пустых родителей. При такой смене требования добавьте проект без задач и проект только с архивом: они выявят разницу гораздо лучше, чем один проект с активной строкой.
Для сложных алиасов укажите ORM, как путь отношения соответствует выбранному alias. Иначе данные могут заполнить не то отношение или запрос получит лишнюю таблицу. Начните с одного отношения, проверьте SQL и ожидаемые ключи, затем расширяйте цепочку. contains_eager предполагает, что вы сами отвечаете за форму соединения.
В материале о загрузке отношений рассматривается полная коллекция без лишних запросов. Здесь задача другая: перенести конкретно отфильтрованные строки собственного JOIN в коллекцию ответа.
Продолжить на практике
Соберите экранный запрос и возвращайте его результат через отдельную модель ответа. В бесплатной программе FastAPI есть SQLAlchemy и тесты CRUD; это основа для проверки состава списка, а не только успешного HTTP-статуса. Откройте бесплатную программу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
