diff --git a/datasette/views/table_create_alter.py b/datasette/views/table_create_alter.py index d4259287..07f13e9d 100644 --- a/datasette/views/table_create_alter.py +++ b/datasette/views/table_create_alter.py @@ -476,6 +476,20 @@ class RenameColumnArgs(_StrictPydanticModel): to: str +class RenameTableArgs(_StrictPydanticModel): + to: str + + @field_validator("to") + @classmethod + def validate_table_name(cls, v): + if not TABLE_NAME_RE.match(v): + raise PydanticCustomError( + "alter_table_rename_table", + "Invalid table name", + ) + return v + + class AlterColumnArgs(_DefaultArgsMixin): name: str type: SqliteApiType | None = None @@ -548,6 +562,11 @@ class RenameColumnOperation(_StrictPydanticModel): args: RenameColumnArgs +class RenameTableOperation(_StrictPydanticModel): + op: Literal["rename_table"] + args: RenameTableArgs + + class AlterColumnOperation(_StrictPydanticModel): op: Literal["alter_column"] args: AlterColumnArgs @@ -587,6 +606,7 @@ AlterTableOperation = Annotated[ Union[ AddColumnOperation, RenameColumnOperation, + RenameTableOperation, AlterColumnOperation, DropColumnOperation, SetPrimaryKeyOperation, @@ -1042,10 +1062,12 @@ class TableAlterView(BaseView): def apply_operations(operation_conn): db_for_write = sqlite_utils.Database(operation_conn) table = db_for_write[table_name] + current_table_name = table_name add_columns = [] types = {} rename = {} + rename_table_to = None drop = set() not_null = {} defaults = {} @@ -1080,6 +1102,8 @@ class TableAlterView(BaseView): defaults[args.name] = _default_expression_sql( args.default_expr ) + elif operation.op == "rename_table": + rename_table_to = args.to elif operation.op == "rename_column": rename[args.name] = args.to elif operation.op == "alter_column": @@ -1155,14 +1179,27 @@ class TableAlterView(BaseView): drop_foreign_keys=drop_foreign_keys or None, foreign_keys=foreign_keys, ) + if ( + rename_table_to is not None + and rename_table_to != current_table_name + ): + operation_conn.execute( + "alter table {} rename to {}".format( + escape_sqlite(current_table_name), + escape_sqlite(rename_table_to), + ) + ) + current_table_name = rename_table_to - return _table_schema_from_conn(operation_conn, table_name) + return current_table_name, _table_schema_from_conn( + operation_conn, current_table_name + ) - after_schema = apply_operations(conn) - return before_schema, after_schema + after_table_name, after_schema = apply_operations(conn) + return before_schema, after_schema, after_table_name try: - before_schema, after_schema = await db.execute_write_fn( + before_schema, after_schema, after_table_name = await db.execute_write_fn( alter_table, request=request ) except Exception as e: @@ -1174,23 +1211,23 @@ class TableAlterView(BaseView): AlterTableEvent( request.actor, database=database_name, - table=table_name, + table=after_table_name, before_schema=before_schema, after_schema=after_schema, ) ) table_url = self.ds.absolute_url( - request, self.ds.urls.table(database_name, table_name) + request, self.ds.urls.table(database_name, after_table_name) ) table_api_url = self.ds.absolute_url( - request, self.ds.urls.table(database_name, table_name, format="json") + request, self.ds.urls.table(database_name, after_table_name, format="json") ) return Response.json( { "ok": True, "database": database_name, - "table": table_name, + "table": after_table_name, "table_url": table_url, "table_api_url": table_api_url, "altered": altered, diff --git a/docs/json_api.rst b/docs/json_api.rst index b2927e38..849a8acf 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -2242,6 +2242,12 @@ The request body should include an ``operations`` array. Each operation has the "to": "headline" } }, + { + "op": "rename_table", + "args": { + "to": "published_posts" + } + }, { "op": "alter_column", "args": { @@ -2299,6 +2305,7 @@ Supported operations: * ``add_column`` adds a new column. ``args`` accepts ``name``, optional ``type`` of ``text``, ``integer``, ``float`` or ``blob``, optional ``not_null``, optional literal ``default`` and optional ``default_expr``. If ``not_null`` is ``true`` either a non-null ``default`` or ``default_expr`` is required. * ``rename_column`` renames a column. ``args`` accepts ``name`` and ``to``. +* ``rename_table`` renames the table. ``args`` accepts ``to``, the new table name. If combined with other operations, Datasette applies the column, primary key, foreign key and column order changes before renaming the table. * ``alter_column`` changes column properties. ``args`` accepts ``name`` and at least one of ``type``, ``not_null``, literal ``default`` or ``default_expr``. Passing ``"default": null`` removes an existing default. * ``drop_column`` drops a column. ``args`` accepts ``name``. * ``set_primary_key`` changes the table primary key. ``args`` accepts ``columns``, a list of one or more column names. @@ -2311,20 +2318,20 @@ Supported operations: For foreign key operations that omit ``fk_column``, the referenced ``fk_table`` must have a single-column primary key. Datasette will return an error if it cannot identify a single primary key column for that table. -A successful response returns the new schema and the previous schema: +A successful response returns the new schema and the previous schema. If the request used ``rename_table``, ``table``, ``table_url`` and ``table_api_url`` will use the new table name. Renaming a table through this endpoint triggers the :class:`~datasette.events.RenameTableEvent` event. .. code-block:: json { "ok": true, "database": "data", - "table": "posts", - "table_url": "http://127.0.0.1:8001/data/posts", - "table_api_url": "http://127.0.0.1:8001/data/posts.json", + "table": "published_posts", + "table_url": "http://127.0.0.1:8001/data/published_posts", + "table_api_url": "http://127.0.0.1:8001/data/published_posts.json", "altered": true, "schema": "CREATE TABLE ...", "before_schema": "CREATE TABLE ...", - "operations_applied": 7 + "operations_applied": 11 } Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 32be8905..ca8a46f5 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -1,4 +1,5 @@ from datasette.app import Datasette +from datasette.events import RenameTableEvent from datasette.utils import sqlite3 from .utils import last_event import pytest @@ -898,6 +899,58 @@ async def test_alter_table_operations(ds_write): assert event.after_schema == data["schema"] +@pytest.mark.asyncio +async def test_alter_table_rename_table(ds_write): + token = write_token(ds_write, permissions=["at"]) + db = ds_write.get_database("data") + before_schema = await db.execute_fn( + lambda conn: conn.execute( + "select sql from sqlite_master where type = 'table' and name = 'docs'" + ).fetchone()[0] + ) + + response = await ds_write.client.post( + "/data/docs/-/alter", + json={ + "operations": [ + {"op": "rename_table", "args": {"to": "documents"}}, + ] + }, + headers=_headers(token), + ) + + assert response.status_code == 200, response.text + data = response.json() + assert data["ok"] is True + assert data["database"] == "data" + assert data["table"] == "documents" + assert data["table_url"].endswith("/data/documents") + assert data["table_api_url"].endswith("/data/documents.json") + assert data["altered"] is True + assert data["operations_applied"] == 1 + assert data["before_schema"] == before_schema + assert 'CREATE TABLE "documents"' in data["schema"] + + tables = ( + await db.execute( + "select name from sqlite_master where type = 'table' order by name" + ) + ).dicts() + table_names = [table["name"] for table in tables] + assert "docs" not in table_names + assert "documents" in table_names + + rename_events = [ + event + for event in ds_write._tracked_events + if isinstance(event, RenameTableEvent) + ] + assert len(rename_events) == 1 + assert rename_events[0].database == "data" + assert rename_events[0].old_table == "docs" + assert rename_events[0].new_table == "documents" + + @pytest.mark.asyncio async def test_alter_table_foreign_key_operations(ds_write): token = write_token(ds_write, permissions=["at"])