diff --git a/datasette/app.py b/datasette/app.py index 6e3e6815..4c98e521 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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[^\/\.]+)/(?P[^\/\.]+)/-/upsert$", ) + add_route( + TableSetColumnTypeView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/set-column-type$", + ) add_route( TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P
[^\/\.]+)/-/drop$", diff --git a/datasette/views/table.py b/datasette/views/table.py index cf5b5b64..a6b13918 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -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" diff --git a/docs/json_api.rst b/docs/json_api.rst index 891aa9e0..7a48a26e 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -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 ``//
/-/set-column-type``. This requires the :ref:`actions_set_column_types` permission. + +:: + + POST //
/-/set-column-type + Content-Type: application/json + Authorization: Bearer dstok_ + +.. 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 diff --git a/tests/test_column_types.py b/tests/test_column_types.py index 538217b0..4fd30812 100644 --- a/tests/test_column_types.py +++ b/tests/test_column_types.py @@ -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()