Skip to content

Commit f66ddcb

Browse files
simonwclaude
andauthored
Transform now refuses to run inside a transaction if destructive foreign keys exist (#795)
* Transform now refuses to run inside a transaction if destructive foreign keys exist Closes #794 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014StVTWQJpFhfZJK2CYVBwv
1 parent d714200 commit f66ddcb

3 files changed

Lines changed: 190 additions & 2 deletions

File tree

docs/python-api.rst

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,9 +434,10 @@ The library will never commit a transaction you opened. If you call write method
434434

435435
Prefer ``db.atomic()`` or ``db.begin()``, ``db.commit()`` and ``db.rollback()`` over mixing sqlite-utils transaction methods with calls to ``db.conn.commit()``, ``db.conn.rollback()`` or raw transaction-control SQL. Mixing the two layers makes it much harder to tell which layer owns the current transaction.
436436

437-
Two related safeguards to be aware of:
437+
Some related safeguards to be aware of:
438438

439439
- ``db.enable_wal()`` and ``db.disable_wal()`` raise a ``sqlite_utils.db.TransactionError`` if called while a transaction is open, because changing the journal mode would commit it as a side effect.
440+
- ``table.transform()`` raises a ``sqlite_utils.db.TransactionError`` if called while a transaction is open with ``PRAGMA foreign_keys`` enabled and the table is referenced by foreign keys with destructive ``ON DELETE`` actions, because the pragma cannot be turned off mid-transaction to protect those referencing rows - see :ref:`python_api_transform_foreign_keys_transactions`.
440441
- Closing the database - explicitly with ``db.close()``, or by exiting a ``with Database(...) as db:`` block - rolls back any transaction that is still open, see :ref:`python_api_close`.
441442

442443
.. _python_api_transactions_modes:
@@ -1996,6 +1997,36 @@ If you want to do something more advanced, you can call the ``table.transform_sq
19961997
19971998
This method will return a list of SQL statements that should be executed to implement the change. You can then make modifications to that SQL - or add additional SQL statements - before executing it yourself.
19981999
2000+
.. _python_api_transform_foreign_keys_transactions:
2001+
2002+
Foreign keys and transactions
2003+
-----------------------------
2004+
2005+
Because ``.transform()`` drops the old table, running it with ``PRAGMA foreign_keys`` enabled could fire ``ON DELETE`` actions on any tables that reference it - an inbound ``ON DELETE CASCADE`` foreign key would silently delete those referencing rows. To prevent this, ``.transform()`` turns ``PRAGMA foreign_keys`` off for the duration of the operation and restores it afterwards, running ``PRAGMA foreign_key_check`` before committing.
2006+
2007+
``PRAGMA foreign_keys`` cannot be changed inside a transaction, so this protection is impossible if you call ``.transform()`` while a transaction is already open - for example inside a ``with db.atomic():`` block or after ``db.begin()``. If ``PRAGMA foreign_keys`` is on and another table references the table being transformed with a destructive ``ON DELETE`` action - ``CASCADE``, ``SET NULL`` or ``SET DEFAULT`` - the method will refuse to run and raise a ``sqlite_utils.db.TransactionError``:
2008+
2009+
.. code-block:: python
2010+
2011+
from sqlite_utils.db import TransactionError
2012+
2013+
try:
2014+
with db.atomic():
2015+
db["authors"].transform(types={"id": str})
2016+
except TransactionError as ex:
2017+
print("Could not transform in transaction:", ex)
2018+
2019+
To transform such a table either call ``.transform()`` outside of the transaction, or execute ``PRAGMA foreign_keys = off`` before opening it:
2020+
2021+
.. code-block:: python
2022+
2023+
db.execute("PRAGMA foreign_keys = off")
2024+
with db.atomic():
2025+
db["authors"].transform(types={"id": str})
2026+
db.execute("PRAGMA foreign_keys = on")
2027+
2028+
Tables referenced by foreign keys without a destructive action (the default ``NO ACTION``, or ``RESTRICT``) can still be transformed inside a transaction - sqlite-utils uses ``PRAGMA defer_foreign_keys`` to postpone the foreign key checks until the transaction commits.
2029+
19992030
.. _python_api_extract:
20002031
20012032
Extracting columns into a separate table

sqlite_utils/db.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2522,6 +2522,11 @@ def transform(
25222522
25232523
See :ref:`python_api_transform` for full details.
25242524
2525+
Raises :py:class:`sqlite_utils.db.TransactionError` if called while a
2526+
transaction is open with ``PRAGMA foreign_keys`` enabled and the table
2527+
is referenced by foreign keys with destructive ``ON DELETE`` actions -
2528+
see :ref:`python_api_transform_foreign_keys_transactions`.
2529+
25252530
:param types: Columns that should have their type changed, for example ``{"weight": float}``
25262531
:param rename: Columns to rename, for example ``{"headline": "title"}``
25272532
:param drop: Columns to drop
@@ -2566,6 +2571,36 @@ def transform(
25662571
should_defer_foreign_keys = (
25672572
pragma_foreign_keys_was_on and already_in_transaction
25682573
)
2574+
if should_defer_foreign_keys:
2575+
# PRAGMA foreign_keys is a no-op inside a transaction, and
2576+
# defer_foreign_keys only defers violation checks, not ON DELETE
2577+
# actions - so dropping the old table would still fire destructive
2578+
# actions on any tables that reference it. Refuse rather than
2579+
# silently modify or delete those rows.
2580+
destructive_fks = [
2581+
(table.name, fk)
2582+
for table in self.db.tables
2583+
for fk in table.foreign_keys
2584+
if fk.other_table == self.name
2585+
and fk.on_delete in ("CASCADE", "SET NULL", "SET DEFAULT")
2586+
]
2587+
if destructive_fks:
2588+
raise TransactionError(
2589+
"Cannot transform table {table} while a transaction is open: "
2590+
"PRAGMA foreign_keys cannot be changed inside a transaction, "
2591+
"and the table is referenced by foreign keys with ON DELETE "
2592+
"actions that would fire when the old table is dropped: "
2593+
"{fks}. Call transform() outside of the transaction, or "
2594+
'execute "PRAGMA foreign_keys = off" before opening it.'.format(
2595+
table=self.name,
2596+
fks=", ".join(
2597+
"{}.{} (ON DELETE {})".format(
2598+
table_name, ", ".join(fk.columns), fk.on_delete
2599+
)
2600+
for table_name, fk in destructive_fks
2601+
),
2602+
)
2603+
)
25692604
defer_foreign_keys_was_on = False
25702605
try:
25712606
if should_disable_foreign_keys:

tests/test_transform.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import sqlite3
22

3-
from sqlite_utils.db import ForeignKey, TransformError
3+
from sqlite_utils.db import ForeignKey, TransactionError, TransformError
44
from sqlite_utils.utils import OperationalError
55
import pytest
66

@@ -469,6 +469,128 @@ def test_transform_on_delete_cascade_does_not_delete_records(
469469
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
470470

471471

472+
@pytest.mark.parametrize("on_delete", ["CASCADE", "SET NULL", "SET DEFAULT", "cascade"])
473+
def test_transform_in_transaction_refuses_destructive_on_delete(fresh_db, on_delete):
474+
# PRAGMA foreign_keys is a no-op inside a transaction, so transforming a
475+
# table referenced by ON DELETE CASCADE / SET NULL / SET DEFAULT foreign
476+
# keys inside an open transaction would fire those actions when the old
477+
# table is dropped - transform() should refuse instead
478+
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
479+
fresh_db.executescript("""
480+
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
481+
CREATE TABLE books (
482+
id INTEGER PRIMARY KEY,
483+
title TEXT,
484+
author_id INTEGER REFERENCES authors(id) ON DELETE {}
485+
);
486+
""".format(on_delete))
487+
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
488+
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
489+
previous_schema = fresh_db["authors"].schema
490+
with fresh_db.atomic():
491+
with pytest.raises(TransactionError) as excinfo:
492+
fresh_db["authors"].transform(rename={"name": "author_name"})
493+
message = str(excinfo.value)
494+
assert "books" in message
495+
assert "ON DELETE {}".format(on_delete.upper()) in message
496+
# Nothing should have changed
497+
assert fresh_db["authors"].schema == previous_schema
498+
assert list(fresh_db["books"].rows) == [
499+
{"id": 1, "title": "The Dispossessed", "author_id": 1}
500+
]
501+
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
502+
503+
504+
def test_transform_in_transaction_refuses_self_referential_cascade(fresh_db):
505+
# The copied table carries a foreign key referencing the original table
506+
# name, so a self-referential cascade would wipe the copy too
507+
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
508+
fresh_db.executescript("""
509+
CREATE TABLE categories (
510+
id INTEGER PRIMARY KEY,
511+
name TEXT,
512+
parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE
513+
);
514+
""")
515+
fresh_db["categories"].insert_all(
516+
[
517+
{"id": 1, "name": "Fiction", "parent_id": None},
518+
{"id": 2, "name": "Science Fiction", "parent_id": 1},
519+
]
520+
)
521+
with fresh_db.atomic():
522+
with pytest.raises(TransactionError) as excinfo:
523+
fresh_db["categories"].transform(rename={"name": "title"})
524+
assert "categories" in str(excinfo.value)
525+
assert fresh_db["categories"].count == 2
526+
527+
528+
def test_transform_in_transaction_allowed_with_no_action_foreign_key(fresh_db):
529+
# An inbound foreign key without a destructive ON DELETE action is safe
530+
# inside a transaction thanks to PRAGMA defer_foreign_keys
531+
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
532+
fresh_db.executescript("""
533+
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
534+
CREATE TABLE books (
535+
id INTEGER PRIMARY KEY,
536+
title TEXT,
537+
author_id INTEGER REFERENCES authors(id)
538+
);
539+
""")
540+
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
541+
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
542+
with fresh_db.atomic():
543+
fresh_db["authors"].transform(rename={"name": "author_name"})
544+
assert list(fresh_db["authors"].rows) == [
545+
{"id": 1, "author_name": "Ursula K. Le Guin"}
546+
]
547+
assert list(fresh_db["books"].rows) == [
548+
{"id": 1, "title": "The Dispossessed", "author_id": 1}
549+
]
550+
assert fresh_db.conn.execute("PRAGMA foreign_keys").fetchone()[0]
551+
552+
553+
def test_transform_in_transaction_allowed_for_child_table(fresh_db):
554+
# The table being transformed only has an outbound foreign key - dropping
555+
# it fires no ON DELETE actions, so this is allowed inside a transaction
556+
fresh_db.conn.execute("PRAGMA foreign_keys=ON")
557+
fresh_db.executescript("""
558+
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
559+
CREATE TABLE books (
560+
id INTEGER PRIMARY KEY,
561+
title TEXT,
562+
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
563+
);
564+
""")
565+
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
566+
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
567+
with fresh_db.atomic():
568+
fresh_db["books"].transform(rename={"title": "book_title"})
569+
assert list(fresh_db["books"].rows) == [
570+
{"id": 1, "book_title": "The Dispossessed", "author_id": 1}
571+
]
572+
573+
574+
def test_transform_in_transaction_allowed_with_foreign_keys_off(fresh_db):
575+
# With PRAGMA foreign_keys off (the default) no cascades can fire, so
576+
# transform inside a transaction is safe even with a CASCADE schema
577+
fresh_db.executescript("""
578+
CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
579+
CREATE TABLE books (
580+
id INTEGER PRIMARY KEY,
581+
title TEXT,
582+
author_id INTEGER REFERENCES authors(id) ON DELETE CASCADE
583+
);
584+
""")
585+
fresh_db["authors"].insert({"id": 1, "name": "Ursula K. Le Guin"})
586+
fresh_db["books"].insert({"id": 1, "title": "The Dispossessed", "author_id": 1})
587+
with fresh_db.atomic():
588+
fresh_db["authors"].transform(rename={"name": "author_name"})
589+
assert list(fresh_db["books"].rows) == [
590+
{"id": 1, "title": "The Dispossessed", "author_id": 1}
591+
]
592+
593+
472594
def test_transform_add_foreign_keys_from_scratch(fresh_db):
473595
_add_country_city_continent(fresh_db)
474596
fresh_db["places"].insert(_CAVEAU)

0 commit comments

Comments
 (0)