From 6c119323d0e42ecb002d573e909207d750f081af Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 16 Sep 2026 09:29:35 -0700 Subject: [PATCH] Keep alter-table changes rollbackable by write wrappers --- datasette/views/table_create_alter.py | 4 +- docs/changelog.rst | 1 + tests/test_write_wrapper.py | 67 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index 85c8d760..927b2c2d 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -1309,7 +1309,9 @@ class TableAlterView(BaseView): elif operation.op == "set_foreign_keys": foreign_keys = [fk.tuple for fk in args.foreign_keys] - with operation_conn: + # Use a savepoint inside execute_write_fn's transaction so + # write_wrapper hooks can still reject and roll back the write. + with db_for_write.atomic(): for column in add_columns: not_null_default = None if column.not_null: diff --git a/docs/changelog.rst b/docs/changelog.rst index a22f2254..e4621fe3 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -26,6 +26,7 @@ Datasette plugins can now use **background tasks** to run code independent of th Bug fixes ~~~~~~~~~ +- The :ref:`alter-table API ` now rolls back schema changes when a :ref:`write_wrapper ` raises after the write. (:issue:`2924`, :pr:`2925`) - CSV endpoints now return plain-text error messages for SQL errors. (:issue:`2129`) - The :ref:`render_cell() ` plugin hook now receives an empty ``pks`` list when rendering SQL views in HTML, matching the JSON ``?_extra=render_cell`` behavior. (:issue:`2639`) - Numeric comparison filters now correctly handle decimal values, negative numbers and scientific notation when filtering computed columns and SQL views. Thanks, `Rami Abdelrazzaq `__. (:issue:`1681`, :pr:`2876`) diff --git a/tests/test_write_wrapper.py b/tests/test_write_wrapper.py index 66599c54..45eea483 100644 --- a/tests/test_write_wrapper.py +++ b/tests/test_write_wrapper.py @@ -351,6 +351,73 @@ async def test_write_wrapper_via_api(tmp_path): pm.unregister(name="test_api") +@pytest.mark.asyncio +@pytest.mark.parametrize("num_sql_threads", (0, 1)) +@pytest.mark.parametrize("in_memory", (True, False)) +@pytest.mark.parametrize( + "operations", + ( + [{"op": "add_column", "args": {"name": "extra", "type": "text"}}], + [{"op": "rename_column", "args": {"name": "id", "to": "renamed_id"}}], + [{"op": "rename_table", "args": {"to": "renamed_t"}}], + [ + {"op": "add_column", "args": {"name": "extra", "type": "text"}}, + {"op": "rename_column", "args": {"name": "id", "to": "renamed_id"}}, + {"op": "rename_table", "args": {"to": "renamed_t"}}, + ], + ), + ids=["add-column", "transform", "rename-table", "combined"], +) +async def test_write_wrapper_can_reject_alter_table_after_write( + tmp_path, num_sql_threads, in_memory, operations +): + """Raising after yield should roll back the schema change.""" + db_path = str(tmp_path / "demo.db") + ds = Datasette( + [] if in_memory else [db_path], + config={"permissions": {"alter-table": True}}, + settings={"num_sql_threads": num_sql_threads}, + ) + db = ( + ds.add_memory_database(db_path, name="demo") + if in_memory + else ds.get_database("demo") + ) + await db.execute_write("CREATE TABLE t (id)") + await db.execute_write("INSERT INTO t (id) VALUES (1)") + before = await db.execute_fn(lambda conn: list(conn.iterdump())) + + class Plugin: + __name__ = "Plugin" + + @staticmethod + @hookimpl + def write_wrapper(database): + def wrapper(conn): + yield + raise ValueError("Rejected after write") + + return wrapper if database == "demo" else None + + pm.register(Plugin(), name="test_reject_alter_table") + try: + response = await ds.client.post( + "/demo/t/-/alter", + json={"operations": operations}, + ) + assert response.status_code == 400 + assert response.json()["errors"] == ["Rejected after write"] + assert await db.execute_fn(lambda conn: list(conn.iterdump())) == before + assert not [ + event + for event in getattr(ds, "_tracked_events", []) + if event.name in ("alter-table", "rename-table") + ] + finally: + pm.unregister(name="test_reject_alter_table") + ds.close() + + @pytest.mark.asyncio async def test_write_wrapper_change_group_pattern(datasette): """Test the motivating use case: activating a change group around a write."""