Alter table API can now rename tables, refs #2788

Refs https://github.com/simonw/datasette/pull/2789#issuecomment-4771774289
This commit is contained in:
Simon Willison 2026-06-22 12:02:51 -07:00
commit 4b219be8bd
3 changed files with 110 additions and 13 deletions

View file

@ -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,

View file

@ -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.

View file

@ -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"])