SELECT вызвал INSERT и упал: разбираем autoflush SQLAlchemy
Обработчик создаёт объект заказа по частям. Сначала добавляет его в Session, затем читает справочник, чтобы заполнить обязательное поле. Но ошибка NOT NULL возникает на строке SELECT, где записи вроде бы нет. ORM перед чтением автоматически отправляет накопленные изменения, и незавершённый объект попадает под ограничения таблицы раньше ожидаемого.
Почему чтение связано с записью
Autoflush поддерживает согласованность запросов с локальными изменениями Session. Иначе поиск мог бы не видеть только что добавленные или изменённые объекты. Но это означает, что ORM SELECT способен сначала вызвать flush. Ошибка относится к накопленному состоянию, а строка чтения всего лишь запускает его отправку в базу.
Первый выбор — собрать корректный объект до add. Если для сборки действительно нужно чтение в той же Session, небольшой участок no_autoflush позволяет отложить автоматическую отправку. После заполнения обязательных полей вызывается явный flush. Это помогает расположить проверку ограничений в понятном месте, а не отключить её вообще.
Сбой до заполнения и исправленный порядок
Нужен SQLAlchemy. Поле name имеет NOT NULL по аннотации Mapped[str]. Первый сценарий демонстрирует реальный IntegrityError. После rollback запускается второй сценарий: поиск выполняется без преждевременного flush, поле заполняется и запись сохраняется. В конце проверяется результат новой Session.
from sqlalchemy import create_engine, select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String, unique=True)
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
with Session(engine) as db:
db.add(Item())
try:
db.scalar(select(func.count()).select_from(Item))
except IntegrityError:
db.rollback()
print({"select_triggered_flush": "IntegrityError"})
else:
raise AssertionError("unfinished object unexpectedly accepted")
with Session(engine) as db:
pending = Item()
db.add(pending)
with db.no_autoflush:
existing = db.scalar(select(func.count()).select_from(Item))
assert existing == 0
pending.name = "assembled"
db.flush()
db.commit()
with Session(engine) as db:
names = db.scalars(select(Item.name)).all()
assert names == ["assembled"]
print({"stored": names})
engine.dispose()
Почему no_autoflush нельзя расширять бесконечно
Пока flush отложен, SQL-запросы не видят неотправленные изменения как строки базы. Поэтому проверка «такое имя уже существует» может пропустить объект, добавленный в текущую Session. Финальную уникальность всё равно обеспечивает ограничение таблицы. Ограничивайте контекст тем местом, где собирается объект, и явно обозначайте последующую точку проверки.
Commit выполняет flush независимо от настройки автоматической отправки перед чтением. begin_nested также имеет собственную обязательную отправку состояния. Поэтому общий autoflush=False не означает «ничего не записывать, пока я не вызову flush». Он меняет конкретные автоматические точки, а завершение транзакции всё равно должно проверить накопленные изменения.
После неудачного flush Session требует rollback перед продолжением работы. Простое except с повторным SELECT оставит её в ошибочном состоянии. В первом сценарии откат сделан явно, после чего Session закрывается. При управлении транзакцией через with db.begin исключение должно выйти из контекста, который выполнит нужную очистку.
Для диагностики включите журнал SQL в локальном окружении и посмотрите команды прямо перед исключением. Часто там обнаружится INSERT, который не виден на проблемной строке Python. Затем найдите место раннего add и решите, можно ли перенести его после сборки. no_autoflush стоит оставлять там, где зависимость сборки от чтения действительно нужна.
Если создание объекта разбросано по нескольким функциям, полезнее вернуть из подготовительной функции готовые значения и только потом добавить сущность. Это делает промежуточное недопустимое состояние локальным. Границы транзакции и Session разбираются в статье об одной Session на запрос; autoflush объясняет неожиданный SQL внутри этих границ.
Продолжить на практике
Разделите сборку команды и сохранение, а ошибку ограничения оставьте в регрессионном тесте. В продвинутом Python есть тестируемый дизайн и Repository: эти темы помогут сократить число функций, которые меняют Session в неожиданный момент. Откройте программу продвинутого Python.
Продолжить на практике
- Python для продвинутых: ООП, типизация и тестирование ↗
Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.
