sqlite-utils 4.1.1: защита от молчаливой потери данных
sqlite-utils 4.1.1 устраняет опасную ошибку: теперь table.transform() выбрасывает TransactionError при работе с внешними ключами внутри транзакции.
Что изменилось в sqlite-utils 4.1.1
Симон Уиллисон выпустил патч-версию своего популярного инструмента для работы с SQLite-базами данных. На первый взгляд изменение выглядит небольшим — исправлена одна конкретная ситуация в методе table.transform(). Но за этим лаконичным описанием скрывается по-настоящему коварная ловушка, которая могла приводить к молчаливому удалению или изменению данных — без единого сообщения об ошибке.
table.transform() внутри транзакций при включённых внешних ключах (PRAGMA foreign_keys = ON) — обновление до 4.1.1 обязательно. Старое поведение могло незаметно уничтожать связанные строки.Суть проблемы: transform, транзакции и внешние ключи
Чтобы понять, что именно исправлено, нужно разобраться в том, как table.transform() работает «под капотом».
table.transform() реализует паттерн, рекомендованный документацией SQLite: создаётся новая временная таблица с нужной схемой, данные копируются туда, старая таблица удаляется, а временная переименовывается на её место.
Этот подход позволяет делать то, что стандартный ALTER TABLE в SQLite не умеет: менять типы столбцов, удалять колонки, переопределять первичные ключи. Оператор ALTER TABLE в SQLite ограничен: он умеет добавлять колонки и переименовывать таблицы, но не может удалять колонки, менять их типы, изменять статус NOT NULL или первичный ключ.
Теперь представьте такой сценарий:
- Открыта явная транзакция (
BEGIN). - Включены внешние ключи:
PRAGMA foreign_keys = ON. - Вызывается
table.transform()на таблице, на которую ссылается другая таблица через внешний ключ с действиемON DELETE CASCADE,ON DELETE SET NULLилиON DELETE SET DEFAULT. - Внутри
transform()удаляется старая версия таблицы. - SQLite срабатывает — и автоматически применяет деструктивное действие к строкам в связанной таблице.
Проблема в том, что это происходило без единого предупреждения. Данные удалялись или обнулялись, а код продолжал работать как ни в чём не бывало.
Почему pragma нельзя изменить внутри транзакции
Здесь есть ещё один тонкий момент, связанный с ограничением SQLite. Включить или отключить ограничения внешних ключей в середине многооператорной транзакции (когда SQLite не находится в режиме автокоммита) невозможно. Попытка сделать это не возвращает ошибку — pragma просто не оказывает никакого эффекта.
Иными словами, если транзакция уже открыта, команда PRAGMA foreign_keys = OFF — стандартный способ безопасно перестроить таблицу — перестаёт работать. Отключить проверку внешних ключей на время трансформации невозможно. Именно поэтому удаление старой таблицы в рамках transform() запускало каскадные действия.
flowchart TD
A["Открыта транзакция\n(BEGIN)"] --> B["PRAGMA foreign_keys = ON"]
B --> C["table.transform() вызван"]
C --> D{"Таблица имеет FK\nс CASCADE/SET NULL/SET DEFAULT?"}
D -- Нет --> E["Трансформация выполняется\nбезопасно"]
D -- Да --> F["Старая таблица удаляется\nвнутри transform()"]
F --> G["SQLite применяет\nдеструктивное действие"]
G --> H["⚠️ Связанные строки\nудалены или изменены\n— без предупреждения"]
style H fill:#ff6b6b,color:#fff
style E fill:#51cf66,color:#fff
Что изменилось в 4.1.1
Теперь метод table.transform() самостоятельно обнаруживает опасную комбинацию условий и выбрасывает исключение TransactionError (ошибка транзакции) до того, как начнёт вносить какие-либо изменения.
Конкретно: исключение возникает, если:
- В данный момент открыта транзакция.
- Включён
PRAGMA foreign_keys. - Таблица, которую нужно трансформировать, является родительской для внешнего ключа с одним из деструктивных действий:
CASCADE,SET NULLилиSET DEFAULT.
TransactionError — исключение из модуля sqlite_utils.db, которое sqlite-utils использует для сигнализации о конфликте между состоянием транзакции и запрошенной операцией. Ранее оно уже применялось, например, при вызове db.enable_wal() внутри транзакции. Методы db.enable_wal() и db.disable_wal() теперь выбрасывают TransactionError, если вызваны в момент, когда транзакция открыта, — ранее они молча коммитили открытую транзакцию как побочный эффект смены режима журнала.Разбор деструктивных действий ON DELETE
Чтобы понять, почему именно CASCADE, SET NULL и SET DEFAULT попали под защиту, стоит разобраться, что каждое из них делает.
| Действие | Что происходит со связанными строками | Потеря данных? |
|---|---|---|
CASCADE | Строки в дочерней таблице удаляются вместе с родительской строкой | Да, необратимо |
SET NULL | В колонке внешнего ключа проставляется NULL | Связь разрушается |
SET DEFAULT | В колонке проставляется значение по умолчанию | Связь разрушается |
NO ACTION / RESTRICT | Транзакция откатывается или выдаётся ошибка | Нет |
Если настроено действие SET NULL, то при удалении родительского ключа (для ON DELETE SET NULL) столбцы дочернего ключа во всех строках дочерней таблицы, ссылавшихся на родительский ключ, получают значение NULL.
Действия SET DEFAULT аналогичны SET NULL, за тем исключением, что каждый из столбцов дочернего ключа получает значение по умолчанию, объявленное для этого столбца, вместо NULL.
Таким образом, три первых действия из таблицы могут привести к необратимым изменениям в связанных данных — причём внутри транзакции, которую разработчик собирался в любой момент откатить.
Как это выглядит на практике
Предположим, у нас есть таблица orders (заказы), которая ссылается на customers (клиенты) с действием ON DELETE CASCADE:
import sqlite_utils
db = sqlite_utils.Database("shop.db")
# Создаём таблицы
db["customers"].insert({"id": 1, "name": "Иван Петров"}, pk="id")
db["orders"].insert(
{"id": 101, "customer_id": 1, "total": 5000},
pk="id",
foreign_keys=[("customer_id", "customers", "id")]
)
# Это было опасно до 4.1.1:
with db.conn: # открываем транзакцию
# PRAGMA foreign_keys уже включён
# transform() удалял таблицу customers внутри — и все orders удалялись каскадно!
db["customers"].transform(column_order=["name", "id"])
В версии 4.1.1 этот код вместо молчаливого удаления заказов немедленно выбросит TransactionError с понятным описанием проблемы.
transform() в подобной ситуации, официальная документация по адресу https://sqlite-utils.datasette.io/en/stable/python-api.html#foreign-keys-and-transactions описывает рекомендуемые обходные пути. Например, можно выполнять трансформацию вне открытой транзакции, или временно отключить PRAGMA foreign_keys до начала транзакции.Контекст: серия улучшений работы с транзакциями в 4.x
Это исправление — часть более широкой работы по повышению надёжности транзакционного поведения в ветке 4.x. Версия 4.0 помимо небольших, но значимых несовместимых изменений представила три крупные возможности: миграции базы данных, вложенные транзакции через новый метод db.atomic(), и поддержку составных внешних ключей.
В версии 4.0 был введён контекстный менеджер db.atomic(), который теперь используется для каждой операции записи — полная модель описана в разделе «Transactions and saving your changes».
Составные операции, такие как table.transform(), больше не коммитят имеющуюся открытую транзакцию — вместо этого они используют точки сохранения (savepoints) внутри неё.
Итог: почему это важно
Каждый раз, когда библиотека молча изменяет данные вместо того, чтобы сообщить об ошибке, она нарушает одно из ключевых правил безопасной разработки: явное лучше неявного. Исправление в 4.1.1 переводит потенциально катастрофическую ситуацию из разряда «тихой потери данных» в разряд «понятного исключения с возможностью принять решение».
Если вы используете sqlite-utils в production-проектах, особенно там, где активно задействованы внешние ключи с каскадными действиями — обновление до 4.1.1 настоятельно рекомендуется.
pip install --upgrade sqlite-utils