Сериализатор неожиданно ходит в базу: ставим raiseload в SQLAlchemy

В репозитории выполняется один SELECT, но сериализатор ответа начинает обращаться к дополнительным отношениям. После добавления нового поля число запросов внезапно растёт. По одному коду функции репозитория это не видно: сеть запускается при чтении project.tasks. Полезно сделать запрещённый доступ явным и поймать его рядом с изменением ответа.

Запрос задаёт разрешённые отношения

raiseload заменяет ленивую загрузку отношения исключением. Вместо незаметного запроса приложение сразу сообщает, что коллекция не была подготовлена. Это особенно удобно для read-сценария с заранее известной формой ответа. Мы сначала запретим все отношения через wildcard, затем разрешим нужную коллекцию явным selectinload.

Защита относится к отношениям, а не ко всем колонкам и не к любому SQL внутри приложения. Для отложенных колонок существуют отдельные настройки. Кроме того, flush может загружать отношения для выполнения своей работы несмотря на raiseload. Поэтому такой тест полезен как договор чтения и сериализации, а не как глобальный запрет базы в произвольном участке кода.

Отказ при чтении и подготовленный ответ

Блок создаёт SQLite в памяти и требует SQLAlchemy. Два сценария используют разные Session: уже загруженное состояние не должно случайно помочь второму запросу. Коллекция проекта действительно содержит задачи, поэтому успешная ветка проверяет полезный результат загрузки.

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 raiseload, selectinload
from sqlalchemy.exc import InvalidRequestError

with Session(engine) as db:
    project = db.scalars(select(Project).where(Project.id == 1)
                         .options(raiseload("*"))).one()
    try:
        list(project.tasks)
    except InvalidRequestError:
        print({"unplanned_relationship": "blocked"})
    else:
        raise AssertionError("unexpected relationship access succeeded")

with Session(engine) as db:
    project = db.scalars(select(Project).where(Project.id == 1).options(
        selectinload(Project.tasks), raiseload("*"))).one()
    result = {"id": project.id, "tasks": sorted(t.id for t in project.tasks)}
    assert result == {"id": 1, "tasks": [10, 11]}
    print(result)
engine.dispose()

Как использовать это в репозитории

Пусть функция запроса получает параметры экрана и возвращает данные с известной загрузкой. Сериализатор использует только подготовленные отношения. Когда появится поле исполнителя задачи, тест упадёт на доступе к нему. Разработчик сможет решить, нужен ли ещё один eager loader, отдельная выборка или вообще другое представление, вместо случайного увеличения числа обращений.

Wildcard распространяется и на отношения связанных объектов, если область не сужена. Это полезная строгая настройка, но сначала посмотрите, где именно возникает отказ. Для ограничения только сущности Project можно использовать Load(Project).raiseload. Явный путь делает крупную модель удобнее для постепенного внедрения: меняется один read-сценарий, а не вся работа приложения.

Сохраните тест формы ответа и, для важного списка, счётчик исполненных SELECT. raiseload обнаруживает незапланированное отношение, а счётчик показывает фактическое число запросов выбранной стратегии. Они решают разные вопросы. Например, случайный запрос внутри свойства Python не является lazy relationship и не будет остановлен этим параметром.

Не исправляйте падение загрузкой всех отношений целиком. Большая коллекция истории может занимать больше времени и памяти, чем исходная проблема. Выберите данные под конкретный экран; иногда достаточно счётчика задач в SQL, а не списка моделей. Таким образом отказ служит точкой проектного решения, а не поводом автоматически расширить граф объектов.

Увидеть сам механизм дополнительных запросов помогает разбор N+1. После его устранения raiseload удобно оставить как защиту от повторного появления скрытой загрузки.

Продолжить на практике

Оформите выборку как часть CRUD и проверьте её в HTTP-тесте. Бесплатный курс FastAPI связывает модели SQLAlchemy с обработчиками и тестами; это удобная среда для явного договора загрузки. Откройте бесплатную программу FastAPI.

Продолжить на практике