From a76646e3fd21ac8aa509674951a65a9808f73454 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 19 Jun 2026 22:51:21 -0700 Subject: [PATCH] GET /db/table/-/insert returns data needed for dialog As part of making the insert dialog less dependent on the table page. --- datasette/static/edit-tools.js | 8 +- datasette/views/table.py | 46 +++++++++++- docs/json_api.rst | 42 ++++++++++- tests/test_table_html.py | 129 ++++++++++++++++++++++++++++++++- 4 files changed, 214 insertions(+), 11 deletions(-) diff --git a/datasette/static/edit-tools.js b/datasette/static/edit-tools.js index 8142d02b..bafd6394 100644 --- a/datasette/static/edit-tools.js +++ b/datasette/static/edit-tools.js @@ -682,7 +682,7 @@ function columnFormControlContext(column, isPk, columnType, options) { database: pageData.database || null, table: pageData.table || - (tableInsertData() && tableInsertData().tableName) || + (tableInsertData() && tableInsertData().table_name) || null, tableUrl: pageData.tableUrl || null, column: column, @@ -1542,7 +1542,7 @@ async function saveRowEditDialog(state) { data && data.rows && data.rows.length ? data.rows[0] : null; var insertedRowId = rowPathFromRowData( insertedRowData, - insertData.primaryKeys || [], + insertData.primary_keys || [], ); state.shouldRestoreFocus = false; if (!insertedRowId) { @@ -1972,8 +1972,8 @@ function openRowInsertDialog(button, manager) { state.dialog.removeAttribute("aria-describedby"); setRowDialogTitle( state.title, - insertData.tableName - ? "Insert row into " + insertData.tableName + insertData.table_name + ? "Insert row into " + insertData.table_name : "Insert row", ); state.summary.hidden = true; diff --git a/datasette/views/table.py b/datasette/views/table.py index c5448c85..9a3fbbc0 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -345,9 +345,9 @@ async def _table_insert_ui( return { "path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)), - "tableName": table_name, + "table_name": table_name, "columns": columns, - "primaryKeys": pks, + "primary_keys": pks, } @@ -655,6 +655,48 @@ class TableInsertView(BaseView): def __init__(self, datasette): self.ds = datasette + async def get(self, request): + try: + resolved = await self.ds.resolve_table(request) + except NotFound as e: + return _error([e.args[0]], 404) + + db = resolved.db + database_name = db.name + table_name = resolved.table + + if resolved.is_view: + return _error(["Cannot insert rows into a view"], 403) + + if not db.is_mutable: + return _error(["Database is immutable"], 403) + + if not await self.ds.allowed( + action="insert-row", + resource=TableResource(database=database_name, table=table_name), + actor=request.actor, + ): + return _error(["Permission denied"], 403) + + pks = await db.primary_keys(table_name) + table_insert_ui = await _table_insert_ui( + self.ds, request, db, database_name, table_name, resolved.is_view, pks + ) + return Response.json( + { + "ok": True, + "insert_row": table_insert_ui, + "table": { + "database": database_name, + "name": table_name, + "url": self.ds.urls.table(database_name, table_name), + }, + "foreign_keys": await _foreign_key_autocomplete_urls( + self.ds, request, db, database_name, table_name + ), + } + ) + async def _validate_data(self, request, db, table_name, pks, upsert): errors = [] diff --git a/docs/json_api.rst b/docs/json_api.rst index f7a0caae..7a20c44b 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -1530,7 +1530,7 @@ Here's how to serve ``data.db`` with CORS enabled:: The JSON write API ------------------ -Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`. +Datasette provides a write API for JSON data. Write operations use ``POST`` and require an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`. .. _ExecuteWriteView: @@ -1643,6 +1643,46 @@ Inserting rows This requires the :ref:`actions_insert_row` permission. +To return metadata describing the insert form for a table, make a ``GET`` request: + +:: + + GET ///-/insert + Authorization: Bearer dstok_ + +This returns a JSON object describing the table, the columns that can be inserted and any foreign key autocomplete URLs available to the actor: + +.. code-block:: json + + { + "ok": true, + "insert_row": { + "path": "/data/dogs/-/insert", + "table_name": "dogs", + "columns": [ + { + "name": "name", + "sqlite_type": "TEXT", + "notnull": 1, + "default": null, + "has_default": false, + "is_pk": false, + "value_kind": "string", + "column_type": null + } + ], + "primary_keys": ["id"] + }, + "table": { + "database": "data", + "name": "dogs", + "url": "/data/dogs" + }, + "foreign_keys": {} + } + +Integer primary key columns that SQLite can populate automatically are omitted from ``insert_row.columns``. If the actor does not have ``insert-row`` permission this endpoint returns a ``403`` response. + A single row can be inserted using the ``"row"`` key: :: diff --git a/tests/test_table_html.py b/tests/test_table_html.py index aa67bb3f..e90a6eb9 100644 --- a/tests/test_table_html.py +++ b/tests/test_table_html.py @@ -982,8 +982,8 @@ async def test_table_insert_action_button_and_data(): insert_data = table_data_from_soup(soup)["insertRow"] assert insert_data["path"] == "/data/items/-/insert" - assert insert_data["tableName"] == "items" - assert insert_data["primaryKeys"] == ["id"] + assert insert_data["table_name"] == "items" + assert insert_data["primary_keys"] == ["id"] assert [column["name"] for column in insert_data["columns"]] == [ "name", "score", @@ -1050,8 +1050,8 @@ async def test_table_insert_action_includes_compound_primary_keys(): insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[ "insertRow" ] - assert insert_data["tableName"] == "memberships" - assert insert_data["primaryKeys"] == ["account", "username"] + assert insert_data["table_name"] == "memberships" + assert insert_data["primary_keys"] == ["account", "username"] assert [column["name"] for column in insert_data["columns"]] == [ "account", "username", @@ -1066,6 +1066,127 @@ async def test_table_insert_action_includes_compound_primary_keys(): ds.close() +@pytest.mark.asyncio +async def test_table_insert_metadata_api(): + ds = Datasette( + [], + config={ + "databases": { + "data": { + "tables": { + "items": { + "permissions": { + "insert-row": {"id": "root"}, + }, + "column_types": {"body": "textarea"}, + }, + }, + }, + }, + }, + ) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_metadata_api"), name="data" + ) + await db.execute_write_script(""" + create table authors ( + id integer primary key, + name text + ); + create table items ( + id integer primary key, + author_id integer references authors(id), + name text not null, + score integer default 5, + body text + ); + """) + response = await ds.client.get("/data/items/-/insert", actor={"id": "root"}) + assert response.status_code == 200 + assert response.json() == { + "ok": True, + "insert_row": { + "path": "/data/items/-/insert", + "table_name": "items", + "columns": [ + { + "name": "author_id", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": False, + "value_kind": "number", + "column_type": None, + }, + { + "name": "name", + "sqlite_type": "TEXT", + "notnull": 1, + "default": None, + "has_default": False, + "is_pk": False, + "value_kind": "string", + "column_type": None, + }, + { + "name": "score", + "sqlite_type": "INTEGER", + "notnull": 0, + "default": "5", + "has_default": True, + "is_pk": False, + "value_kind": "number", + "column_type": None, + }, + { + "name": "body", + "sqlite_type": "TEXT", + "notnull": 0, + "default": None, + "has_default": False, + "is_pk": False, + "value_kind": "string", + "column_type": {"type": "textarea", "config": None}, + }, + ], + "primary_keys": ["id"], + }, + "table": { + "database": "data", + "name": "items", + "url": "/data/items", + }, + "foreign_keys": { + "author_id": "/data/authors/-/autocomplete", + }, + } + finally: + ds.close() + + +@pytest.mark.asyncio +async def test_table_insert_metadata_api_requires_insert_permission(): + ds = Datasette([]) + try: + db = ds.add_database( + Database(ds, memory_name="test_table_insert_metadata_permission"), + name="data", + ) + await db.execute_write_script(""" + create table items (id integer primary key, name text); + """) + response = await ds.client.get("/data/items/-/insert") + assert response.status_code == 403 + assert response.json() == { + "ok": False, + "errors": ["Permission denied"], + } + finally: + ds.close() + + @pytest.mark.asyncio async def test_table_data_includes_foreign_key_autocomplete_urls(): ds = Datasette([])