Add db.begin(), db.commit() and db.rollback() methods

Manual transaction control previously required mixing raw
db.execute("begin") strings with methods on db.conn. These thin
wrappers give the documentation and callers a single consistent
idiom. Docs updated to use them throughout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnLnhsH25Nnv7LHhekUfPd
This commit is contained in:
Claude 2026-07-04 22:31:22 +00:00
commit 788c393a30
No known key found for this signature in database
5 changed files with 75 additions and 6 deletions

View file

@ -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. - ``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`) - ``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. - 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. - 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: .. _v4_0rc1:

View file

@ -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. 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 Applying migrations using the CLI
================================= =================================

View file

@ -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 writes or an explicit ``BEGIN``. In that case the decision to commit stays
with you: committing automatically on exit could silently persist with you: committing automatically on exit could silently persist
half-finished work, for example if your code returned early from the block. 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. ``db.atomic()`` which commits for you.
Note this differs from the ``sqlite3.Connection`` context manager in the 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() Raw SQL writes with db.execute()
-------------------------------- --------------------------------
:ref:`db.execute() <python_api_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() <python_api_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 .. code-block:: python
db.execute("insert into news (headline) values (?)", ["Dog wins award"]) 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: 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 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. 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.

View file

@ -455,6 +455,31 @@ class Database:
self.conn.rollback() self.conn.rollback()
raise 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 @contextlib.contextmanager
def ensure_autocommit_off(self) -> Generator[None, None, None]: def ensure_autocommit_off(self) -> Generator[None, None, None]:
""" """

View file

@ -1,6 +1,6 @@
import pytest 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 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["t"].insert({"id": 3}, pk="id")
fresh_db.conn.commit() fresh_db.conn.commit()
assert [r["id"] for r in fresh_db["t"].rows] == [1, 3] 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