/db/table/-/set-column-type JSON API, refs #2671

This commit is contained in:
Simon Willison 2026-03-18 12:15:42 -07:00
commit 3f5dd2b876
4 changed files with 405 additions and 0 deletions

View file

@ -68,6 +68,7 @@ from .views.special import (
from .views.table import (
TableInsertView,
TableUpsertView,
TableSetColumnTypeView,
TableDropView,
table_view,
)
@ -2240,6 +2241,10 @@ class Datasette:
TableUpsertView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/upsert$",
)
add_route(
TableSetColumnTypeView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
)
add_route(
TableDropView.as_view(self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",

View file

@ -666,6 +666,122 @@ class TableUpsertView(TableInsertView):
return await super().post(request, upsert=True)
class TableSetColumnTypeView(BaseView):
name = "table-set-column-type"
def __init__(self, datasette):
self.ds = datasette
async def post(self, request):
try:
resolved = await self.ds.resolve_table(request)
except NotFound as e:
return _error([e.args[0]], 404)
database_name = resolved.db.name
table_name = resolved.table
if not await self.ds.allowed(
action="set-column-types",
resource=TableResource(database=database_name, table=table_name),
actor=request.actor,
):
return _error(["Permission denied"], 403)
content_type = request.headers.get("content-type") or ""
if not content_type.startswith("application/json"):
return _error(["Invalid content-type, must be application/json"], 400)
try:
data = json.loads(await request.post_body())
except json.JSONDecodeError as e:
return _error(["Invalid JSON: {}".format(e)], 400)
if not isinstance(data, dict):
return _error(["JSON must be a dictionary"], 400)
invalid_keys = set(data.keys()) - {"column", "column_type"}
if invalid_keys:
return _error(
['Invalid parameter: "{}"'.format('", "'.join(sorted(invalid_keys)))],
400,
)
if "column" not in data:
return _error(['"column" is required'], 400)
column = data["column"]
if not isinstance(column, str):
return _error(['"column" must be a string'], 400)
if "column_type" not in data:
return _error(['"column_type" is required'], 400)
column_details = await self.ds._get_resource_column_details(
database_name, table_name
)
if column not in column_details:
return _error(["Column not found: {}".format(column)], 400)
column_type_data = data["column_type"]
if column_type_data is None:
await self.ds.remove_column_type(database_name, table_name, column)
return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"column": column,
"column_type": None,
},
status=200,
)
if not isinstance(column_type_data, dict):
return _error(['"column_type" must be an object or null'], 400)
invalid_column_type_keys = set(column_type_data.keys()) - {"type", "config"}
if invalid_column_type_keys:
return _error(
[
'Invalid column_type parameter: "{}"'.format(
'", "'.join(sorted(invalid_column_type_keys))
)
],
400,
)
if "type" not in column_type_data:
return _error(['"column_type.type" is required'], 400)
column_type = column_type_data["type"]
if not isinstance(column_type, str):
return _error(['"column_type.type" must be a string'], 400)
config = column_type_data.get("config")
if config is not None and not isinstance(config, dict):
return _error(['"column_type.config" must be a dictionary'], 400)
if column_type not in self.ds._column_types:
return _error(["Unknown column type: {}".format(column_type)], 400)
try:
await self.ds.set_column_type(
database_name, table_name, column, column_type, config
)
except ValueError as e:
return _error([str(e)], 400)
return Response.json(
{
"ok": True,
"database": database_name,
"table": table_name,
"column": column,
"column_type": {"type": column_type, "config": config},
},
status=200,
)
class TableDropView(BaseView):
name = "table-drop"

View file

@ -941,6 +941,70 @@ To use the ``"replace": true`` option you will also need the :ref:`actions_updat
Pass ``"alter": true`` to automatically add any missing columns to the existing table that are present in the rows you are submitting. This requires the :ref:`actions_alter_table` permission.
.. _TableSetColumnTypeView:
Setting a column type
~~~~~~~~~~~~~~~~~~~~~
To set a column type for a table column, make a ``POST`` to ``/<database>/<table>/-/set-column-type``. This requires the :ref:`actions_set_column_types` permission.
::
POST /<database>/<table>/-/set-column-type
Content-Type: application/json
Authorization: Bearer dstok_<rest-of-token>
.. code-block:: json
{
"column": "title",
"column_type": {
"type": "email"
}
}
This will return a ``200`` response like this:
.. code-block:: json
{
"ok": true,
"database": "data",
"table": "posts",
"column": "title",
"column_type": {
"type": "email",
"config": null
}
}
To provide column type configuration, include a ``config`` object:
.. code-block:: json
{
"column": "title",
"column_type": {
"type": "url",
"config": {
"max_length": 200
}
}
}
To clear an existing column type assignment, set ``column_type`` to ``null``:
.. code-block:: json
{
"column": "title",
"column_type": null
}
This API stores the assignment in Datasette's internal database, so it can be used with immutable databases as well as mutable ones.
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.
.. _TableDropView:
Dropping tables

View file

@ -186,6 +186,226 @@ async def test_set_column_type_with_config(ds_ct):
assert ct.config == {"max_length": 200}
@pytest.mark.asyncio
async def test_set_column_type_api(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct, permissions=["sct"])
response = await ds_ct.client.post(
"/data/posts/-/set-column-type",
json={"column": "title", "column_type": {"type": "email"}},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json() == {
"ok": True,
"database": "data",
"table": "posts",
"column": "title",
"column_type": {"type": "email", "config": None},
}
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct.name == "email"
assert ct.config is None
@pytest.mark.asyncio
async def test_set_column_type_api_with_config(ds_ct):
await ds_ct.invoke_startup()
token = write_token(ds_ct, permissions=["sct"])
response = await ds_ct.client.post(
"/data/posts/-/set-column-type",
json={
"column": "title",
"column_type": {"type": "url", "config": {"max_length": 200}},
},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json()["column_type"] == {
"type": "url",
"config": {"max_length": 200},
}
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct.name == "url"
assert ct.config == {"max_length": 200}
@pytest.mark.asyncio
async def test_clear_column_type_api(ds_ct):
await ds_ct.invoke_startup()
await ds_ct.set_column_type("data", "posts", "title", "email")
token = write_token(ds_ct, permissions=["sct"])
response = await ds_ct.client.post(
"/data/posts/-/set-column-type",
json={"column": "title", "column_type": None},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json() == {
"ok": True,
"database": "data",
"table": "posts",
"column": "title",
"column_type": None,
}
ct = await ds_ct.get_column_type("data", "posts", "title")
assert ct is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
"body,special_case,expected_status,expected_errors",
(
(
{"column": "title", "column_type": {"type": "email"}},
"no_permission",
403,
["Permission denied"],
),
(
None,
"invalid_json",
400,
[
"Invalid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"
],
),
(
{"column": "title", "column_type": {"type": "email"}},
"invalid_content_type",
400,
["Invalid content-type, must be application/json"],
),
(
[],
None,
400,
["JSON must be a dictionary"],
),
(
{"column_type": {"type": "email"}},
None,
400,
['"column" is required'],
),
(
{"column": 1, "column_type": {"type": "email"}},
None,
400,
['"column" must be a string'],
),
(
{"column": "not_a_column", "column_type": {"type": "email"}},
None,
400,
["Column not found: not_a_column"],
),
(
{"column": "title", "column_type": "email"},
None,
400,
['"column_type" must be an object or null'],
),
(
{"column": "title", "column_type": {}},
None,
400,
['"column_type.type" is required'],
),
(
{"column": "title", "column_type": {"type": 1}},
None,
400,
['"column_type.type" must be a string'],
),
(
{"column": "title", "column_type": {"type": "url", "config": []}},
None,
400,
['"column_type.config" must be a dictionary'],
),
(
{"column": "title", "column_type": {"type": "markdown"}},
None,
400,
["Unknown column type: markdown"],
),
(
{"column": "id", "column_type": {"type": "json"}},
None,
400,
[
"Column type 'json' is only applicable to SQLite types TEXT but data.posts.id has SQLite type INTEGER"
],
),
(
{
"column": "title",
"column_type": {"type": "email"},
"extra": True,
},
None,
400,
['Invalid parameter: "extra"'],
),
),
)
async def test_set_column_type_api_errors(
ds_ct, body, special_case, expected_status, expected_errors
):
await ds_ct.invoke_startup()
token = write_token(
ds_ct,
permissions=(["sct"] if special_case != "no_permission" else ["vi"]),
)
kwargs = {
"headers": {
"Authorization": f"Bearer {token}",
"Content-Type": (
"text/plain"
if special_case == "invalid_content_type"
else "application/json"
),
}
}
if special_case == "invalid_json":
kwargs["content"] = "{bad json"
else:
kwargs["json"] = body
response = await ds_ct.client.post("/data/posts/-/set-column-type", **kwargs)
assert response.status_code == expected_status
assert response.json() == {"ok": False, "errors": expected_errors}
@pytest.mark.asyncio
async def test_set_column_type_api_works_for_immutable_database(tmp_path_factory):
db_directory = tmp_path_factory.mktemp("dbs")
db_path = str(db_directory / "immutable.db")
db = sqlite3.connect(str(db_path))
db.execute("vacuum")
db.execute("create table posts (id integer primary key, title text)")
db.commit()
ds = Datasette([], immutables=[db_path])
ds.root_enabled = True
try:
await ds.invoke_startup()
token = write_token(ds, permissions=["sct"])
response = await ds.client.post(
"/immutable/posts/-/set-column-type",
json={"column": "title", "column_type": {"type": "email"}},
headers=_headers(token),
)
assert response.status_code == 200
assert response.json()["column_type"] == {"type": "email", "config": None}
ct = await ds.get_column_type("immutable", "posts", "title")
assert ct.name == "email"
finally:
db.close()
for database in ds.databases.values():
if not database.is_memory:
database.close()
@pytest.mark.asyncio
async def test_set_column_type_rejects_incompatible_sqlite_type(ds_ct):
await ds_ct.invoke_startup()