SQLAlchemyMiddleExperience

Какая mental model SQLAlchemy важнее всего: unit of work, change tracking, query generation, migrations или generated client?

Центральная mental model — Unit of Work: сессия накапливает изменения и сбрасывает их единым батчем. Всё остальное (change tracking, query generation, migrations) — следствие или надстройка над этой концепцией.

Unit of Work — центральная mental model SQLAlchemy

Из всех концепций SQLAlchemy самой важной для практической работы является Unit of Work. Остальные — change tracking, query generation, migrations, generated client — либо производные от неё, либо вспомогательные инструменты. Понимание Unit of Work объясняет, почему ORM ведёт себя именно так, как ведёт, и предотвращает большую часть типичных ошибок.

Что такое Unit of Work

Unit of Work — это паттерн (описан Мартином Фаулером), при котором объект-сессия отслеживает все изменения, внесённые за время транзакции, и сбрасывает их в БД единым согласованным батчем в момент коммита или явного flush(). SQLAlchemy Session является классической реализацией этого паттерна.

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from models import User, Address

engine = create_engine("postgresql+psycopg2://user:pw@localhost/db")

with Session(engine) as session:
    # 1. Загружаем объект — он теперь "tracked"
    user = session.get(User, 42)

    # 2. Меняем атрибут — изменение запоминается в identity map
    user.name = "Alice"

    # 3. Добавляем связанный объект
    addr = Address(city="Berlin", user=user)
    session.add(addr)

    # 4. flush() превращает накопленные изменения в SQL
    #    (происходит автоматически перед коммитом)
    session.flush()
    # INSERT INTO addresses ... + UPDATE users SET name=... выполняются здесь

    session.commit()
    # транзакция фиксируется; identity map сбрасывается (expire_on_commit=True)

Change Tracking как следствие Unit of Work

SQLAlchemy отслеживает изменения атрибутов через инструментирование (instrumented attributes). Каждый столбец модели заменяется дескриптором, который при записи помечает объект как «грязный» (dirty) в сессии. Это позволяет автоматически генерировать UPDATE только для изменившихся столбцов без явного указания.

from sqlalchemy import inspect

with Session(engine) as session:
    user = session.get(User, 42)
    user.email = "new@example.com"

    insp = inspect(user)
    print(insp.attrs.email.history)  # History(added=['new@example.com'], unchanged=(), deleted=['old@example.com'])
    print(session.dirty)             # {<User id=42>}

Query Generation — инструмент, а не суть

ORM-запросы через select(User).where(User.age > 30) удобны, но это лишь DSL над SQL. Главное, что query generation работает внутри той же сессии и возвращает объекты, уже помещённые в identity map. Если вы загрузили объект через get() и потом делаете select() с тем же PK — вы получаете тот же Python-объект из памяти, а не новый из БД.

Migrations и Generated Client — отдельные слои

Alembic (migrations) работает независимо от сессии и Unit of Work — он сравнивает метаданные моделей с реальной схемой БД и генерирует DDL-скрипты. Generated client (например, через automap_base() или sqlc) — ещё один вспомогательный инструмент для случаев, когда схема первична.

Почему именно Unit of Work важнее всего

  • Объясняет, зачем нужен session.add() и что происходит при session.commit().
  • Помогает понять expire_on_commit=True: после коммита все атрибуты помечаются устаревшими, следующее обращение — новый SELECT.
  • Объясняет DetachedInstanceError: объект вне сессии не может лениво загружать атрибуты.
  • Объясняет, почему два session.get(User, 42) подряд возвращают один объект (identity map).
  • Помогает правильно выбрать границы транзакции и избежать «фантомных» запросов.

Подводные камни

  • expire_on_commit: после коммита каждое обращение к атрибуту вне открытой сессии вызывает отдельный SELECT. В async-коде это приводит к MissingGreenlet/greenlet_spawn ошибкам.
  • Autoflush: SQLAlchemy делает flush() перед каждым запросом по умолчанию (autoflush=True). Это ломает ожидания, когда вы не готовы к записи в этот момент.
  • Identity map и stale data: объект в памяти не обновляется автоматически, если другой процесс изменил строку в БД. session.refresh(obj) решает проблему.
  • Область жизни сессии: сессия не является потокобезопасной и не должна шариться между запросами. В FastAPI это решается через async_scoped_session или dependency injection.
  • Cascade без осознания: cascade="all, delete-orphan" удалит дочерние записи при удалении родителя — это не всегда желательное поведение.
  • Слишком долгая сессия: держать открытую сессию через несколько HTTP-запросов — антипаттерн; identity map разрастается и данные стареют.
  • Detached objects: передача ORM-объектов за пределы сессии без eager load всех нужных атрибутов приводит к DetachedInstanceError в продакшне.
  • bulk operations обходят Unit of Work: session.execute(update(User).values(...)) не обновит уже загруженные в identity map объекты.

What hurts your answer

  • Знать термины SQLAlchemy, но не понимать связи между абстракциями
  • Объяснять поведение через отдельные примеры вместо причинной модели
  • Не связывать mental model с диагностикой ошибок

What they're listening for

  • Понимает ключевые абстракции SQLAlchemy
  • Может предсказывать поведение системы через mental model
  • Связывает модель с debugging и production decisions

Related topics