Родитель удалён, дети остались: ON DELETE CASCADE в SQLAlchemy и SQLite
В модели указан ON DELETE CASCADE, но после удаления проекта в тестовой SQLite остаются задачи. При ручной проверке через PostgreSQL всё работает, поэтому дефект кажется случайным. Для каскада важны сразу две стороны: кто выполняет удаление дочерних строк и включено ли ограничение внешнего ключа на конкретном соединении.
Выберите исполнителя каскада
ORM может удалить загруженные дочерние объекты сам. База может удалить строки по ON DELETE CASCADE. passive_deletes=True позволяет для незагруженной коллекции положиться на базу, не читая её ради удаления. При этом настройка cascade отношения должна соответствовать предметному смыслу: удаление родителя действительно уничтожает детей.
SQLite требует включения PRAGMA foreign_keys=ON на соединениях. Одной записи ForeignKey в модели недостаточно. В приложении с пулом соединений настройку выполняют при событии connect. На sqlite3 с атрибутом autocommit обработчик временно включает его для PRAGMA и восстанавливает прежнее значение: команда настройки должна выполниться вне транзакции. В примере создадим два независимых движка: без настройки и с ней. Они используют базы в памяти и удаляются после проверки.
Незагруженная коллекция обнаружит проблему
Нужен SQLAlchemy. Создадим родителя с ребёнком в одной Session, затем удалим только родителя в новой. Задачи не читаются, иначе ORM мог бы удалить их сам и скрыть отсутствие каскада базы. Проверка считает реальные оставшиеся строки после commit.
from sqlalchemy import create_engine, ForeignKey, select, func, event
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(
cascade="all, delete-orphan", passive_deletes=True)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"))
def remaining(enable_fk):
engine = create_engine("sqlite://")
if enable_fk:
@event.listens_for(engine, "connect")
def set_foreign_keys(connection, record):
has_autocommit = hasattr(connection, "autocommit")
if has_autocommit:
previous = connection.autocommit
connection.autocommit = True
cursor = connection.cursor()
try:
cursor.execute("PRAGMA foreign_keys=ON")
finally:
cursor.close()
if has_autocommit:
connection.autocommit = previous
Base.metadata.create_all(engine)
with Session(engine) as db:
db.add(Project(id=1, tasks=[Task(id=10)]))
db.commit()
with Session(engine) as db:
project = db.get(Project, 1)
assert "tasks" not in project.__dict__
db.delete(project)
db.commit()
with Session(engine) as db:
count = db.scalar(select(func.count()).select_from(Task))
engine.dispose()
return count
broken, fixed = remaining(False), remaining(True)
assert (broken, fixed) == (1, 0)
print({"foreign_keys_off": broken, "foreign_keys_on": fixed})
Почему тест с прочитанными детьми мог пройти
При обычной ORM-работе загруженная коллекция и cascade удаления позволяют Session отправить DELETE детей. Тест тогда подтверждает другой путь исполнения, а не ON DELETE базы. Поэтому нужны хотя бы два сценария: коллекция загружена и коллекция не загружена. Для второго важно открыть новую Session и не обращаться к отношению до удаления.
Если удаление выполняется массовой SQL-командой delete(Project), ORM-каскад отдельных экземпляров не запускается так же, как при db.delete(project). Полагаться на него нельзя. Именно каскад базы должен поддержать согласованность строк для такого пути. Добавьте отдельный тест массовой команды, если приложение действительно использует её в очистке данных.
ON DELETE CASCADE подходит не каждой связи. Для истории платежей или аудита удаление вместе с владельцем может нарушить требования продукта. Иногда правильнее запрет удаления через RESTRICT, а иногда — архивирование. Сначала решите смысл жизненного цикла дочерней записи; параметр ORM лишь реализует этот выбор.
Изменение модели не меняет уже существующее ограничение таблицы. В рабочей базе нужно применить соответствующую миграцию и проверить фактическое определение внешнего ключа. SQLite-пример создаёт новую схему, поэтому не проверяет ваш процесс миграций. В тестовом окружении с постоянным файлом базы старое ограничение способно пережить обновление Python-класса.
Для HTTP-уровня дополнительно проверьте повторный DELETE и чтение удалённого ресурса. Успешный статус сам по себе ничего не говорит о дочерних строках: важна отдельная проверка состояния базы. Основы работы Session показаны в материале о Session на запрос.
Продолжить на практике
Соберите связанную пару таблиц и проверьте состояние после удаления через endpoint. Бесплатный курс FastAPI соединяет SQLAlchemy, CRUD и тесты, на которых удобно увидеть разницу между HTTP-успехом и фактическими строками базы. Откройте бесплатную программу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
