diff --git a/docs/changelog.rst b/docs/changelog.rst index 04d5405..baff4af 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,7 @@ Everything else: - ``table.upsert()`` and ``table.upsert_all()`` now detect the primary key or compound primary key of an existing table, so the ``pk=`` argument is no longer required when upserting into a table that already has a primary key. - ``db.table(table_name).insert({})`` can now be used to insert a row consisting entirely of default values into an existing table, using ``INSERT INTO ... DEFAULT VALUES``. (:issue:`759`) - Improvements to the ``sqlite-utils migrate`` command: ``--stop-before`` values that do not match any known migration are now an error instead of being silently ignored, ``--stop-before`` now works correctly with migration files that still use the older ``sqlite_migrate.Migrations`` class, and ``--list`` is now a read-only operation that no longer creates the database file or the migrations tracking table. ``migrations.applied()`` now returns migrations in the order they were applied. +- New ``db.begin()``, ``db.commit()`` and ``db.rollback()`` methods for taking manual control of transactions, as an alternative to the ``db.atomic()`` context manager. - New documentation: :ref:`python_api_transactions` describes how transactions work and when changes are committed, and a new :ref:`upgrading` page details the changes needed to move between major versions. .. _v4_0rc1: diff --git a/docs/migrations.rst b/docs/migrations.rst index 1bd1457..e685936 100644 --- a/docs/migrations.rst +++ b/docs/migrations.rst @@ -87,7 +87,7 @@ Some operations cannot run inside a transaction, for example ``VACUUM`` or chang A migration registered with ``transactional=False`` runs without a wrapping transaction, so if it fails part way through any changes it already made will not be rolled back, and re-applying will run the whole function again. -Avoid calling ``db.conn.commit()`` or otherwise managing transactions manually inside a transactional migration - register the migration with ``transactional=False`` if it needs to control its own transactions. Using ``with db.atomic():`` blocks inside a migration is fine: they nest as savepoints within the migration's transaction, so the migration as a whole still commits or rolls back as a single unit. See :ref:`python_api_transactions`. +Avoid calling ``db.commit()`` or otherwise managing transactions manually inside a transactional migration - register the migration with ``transactional=False`` if it needs to control its own transactions. Using ``with db.atomic():`` blocks inside a migration is fine: they nest as savepoints within the migration's transaction, so the migration as a whole still commits or rolls back as a single unit. See :ref:`python_api_transactions`. Applying migrations using the CLI ================================= diff --git a/docs/python-api.rst b/docs/python-api.rst index 163dad5..400c0fa 100644 --- a/docs/python-api.rst +++ b/docs/python-api.rst @@ -156,7 +156,7 @@ only be left open here if you opened it yourself - through raw ``db.execute()`` writes or an explicit ``BEGIN``. In that case the decision to commit stays with you: committing automatically on exit could silently persist half-finished work, for example if your code returned early from the block. -Commit explicitly with ``db.conn.commit()``, or wrap your writes in +Commit explicitly with ``db.commit()``, or wrap your writes in ``db.atomic()`` which commits for you. Note this differs from the ``sqlite3.Connection`` context manager in the @@ -350,12 +350,12 @@ The transaction is opened with a deferred ``BEGIN`` - SQLite takes the necessary Raw SQL writes with db.execute() -------------------------------- -:ref:`db.execute() ` is a thin wrapper around the underlying ``sqlite3`` connection, and it follows that connection's rules rather than this library's: a write statement opens a transaction that stays open until something commits it. Commit explicitly: +:ref:`db.execute() ` is a thin wrapper around the underlying ``sqlite3`` connection, and it follows that connection's rules rather than this library's: a write statement opens a transaction that stays open until something commits it. Commit explicitly with ``db.commit()``: .. code-block:: python db.execute("insert into news (headline) values (?)", ["Dog wins award"]) - db.conn.commit() + db.commit() Or wrap the calls in ``db.atomic()``, which commits for you: @@ -371,7 +371,18 @@ If you do neither, the write can be deceptive: reads on the same connection will Managing transactions yourself ------------------------------ -You can take full manual control using ``db.execute("begin")`` (or any raw write, as above) followed by ``db.conn.commit()`` or ``db.conn.rollback()``. +You can take full manual control using the ``db.begin()``, ``db.commit()`` and ``db.rollback()`` methods: + +.. code-block:: python + + db.begin() + db.table("news").insert({"headline": "Dog wins award"}) + if all_looks_good: + db.commit() + else: + db.rollback() + +``db.begin()`` raises ``sqlite3.OperationalError`` if a transaction is already open. ``db.commit()`` and ``db.rollback()`` do nothing if there is no open transaction. A raw write executed with ``db.execute()`` (as above) opens a transaction implicitly, without needing ``db.begin()``. The library will never commit a transaction you opened. If you call write methods such as ``insert()`` - or use ``db.atomic()`` - while your transaction is open, they participate in it using SQLite savepoints instead of committing: exiting an ``atomic()`` block releases its savepoint, but nothing is saved to disk until you commit the outer transaction yourself. If you roll back, their changes are rolled back too. diff --git a/sqlite_utils/db.py b/sqlite_utils/db.py index 49d9f63..aeafe56 100644 --- a/sqlite_utils/db.py +++ b/sqlite_utils/db.py @@ -455,6 +455,31 @@ class Database: self.conn.rollback() raise + def begin(self) -> None: + """ + Start a transaction with ``BEGIN``, taking manual control of transaction + handling. End it by calling :meth:`commit` or :meth:`rollback`. + + Raises ``sqlite3.OperationalError`` if a transaction is already open. + + Most code should use the :meth:`atomic` context manager instead, which + commits and rolls back automatically. See :ref:`python_api_transactions`. + """ + self.execute("BEGIN") + + def commit(self) -> None: + """ + Commit the current transaction. Does nothing if no transaction is open. + """ + self.conn.commit() + + def rollback(self) -> None: + """ + Roll back the current transaction, discarding its changes. Does nothing + if no transaction is open. + """ + self.conn.rollback() + @contextlib.contextmanager def ensure_autocommit_off(self) -> Generator[None, None, None]: """ diff --git a/tests/test_atomic.py b/tests/test_atomic.py index 8c4d18d..b75bc6c 100644 --- a/tests/test_atomic.py +++ b/tests/test_atomic.py @@ -1,6 +1,6 @@ import pytest -from sqlite_utils.db import _iter_complete_sql_statements +from sqlite_utils.db import Database, _iter_complete_sql_statements from sqlite_utils.utils import sqlite3 @@ -189,3 +189,35 @@ def test_atomic_inside_manual_transaction_uses_savepoint(fresh_db): fresh_db["t"].insert({"id": 3}, pk="id") fresh_db.conn.commit() assert [r["id"] for r in fresh_db["t"].rows] == [1, 3] + + +def test_begin_commit_rollback(tmpdir): + path = str(tmpdir / "test.db") + db = Database(path) + db["t"].insert({"id": 1}, pk="id") + db.begin() + db["t"].insert({"id": 2}, pk="id") + assert db.conn.in_transaction + db.rollback() + assert not db.conn.in_transaction + assert [r["id"] for r in db["t"].rows] == [1] + db.begin() + db["t"].insert({"id": 3}, pk="id") + db.commit() + db.close() + db2 = Database(path) + assert [r["id"] for r in db2["t"].rows] == [1, 3] + db2.close() + + +def test_begin_inside_transaction_errors(fresh_db): + fresh_db.begin() + with pytest.raises(sqlite3.OperationalError): + fresh_db.begin() + fresh_db.rollback() + + +def test_commit_and_rollback_without_transaction_are_noops(fresh_db): + fresh_db.commit() + fresh_db.rollback() + assert not fresh_db.conn.in_transaction