From 2d77e3334b48417c5e27355bb4016c7c76acf30e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 24 May 2026 23:06:01 -0700 Subject: [PATCH] Clean up query management test coverage Refs #2735 --- datasette/views/database.py | 6 ++-- docs/json_api.rst | 42 +++++++++++++++++++++++ tests/plugins/my_plugin_2.py | 14 -------- tests/test_canned_queries.py | 4 --- tests/test_html.py | 2 -- tests/test_permissions.py | 1 - tests/test_plugins.py | 65 ++++++++++++++++++++++++++++-------- 7 files changed, 96 insertions(+), 38 deletions(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 2cdaab9f..d521f7ad 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -697,9 +697,9 @@ async def _prepare_query_update(datasette, request, db, existing, update): "on_error_redirect": update.get("on_error_redirect"), } update_kwargs = {} - for field, value in field_values.items(): - if field in update: - update_kwargs[field] = value + for field_name, value in field_values.items(): + if field_name in update: + update_kwargs[field_name] = value if parameters is not None: update_kwargs["parameters"] = parameters if "sql" in update: diff --git a/docs/json_api.rst b/docs/json_api.rst index 48c70af6..d5cd231c 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -505,6 +505,48 @@ 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`. +.. _QueryListView: + +Listing saved queries +~~~~~~~~~~~~~~~~~~~~~ + +``GET //-/queries`` returns saved query definitions the actor can view. + +.. _QueryCreateView: + +Creating saved queries in the UI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``GET //-/queries/-/create`` provides a form for creating saved queries. + +.. _QueryInsertView: + +Creating saved queries +~~~~~~~~~~~~~~~~~~~~~~ + +``POST //-/queries/-/insert`` creates a saved query. This requires ``execute-sql`` and ``insert-query`` for the database. + +.. _QueryDefinitionView: + +Getting a saved query definition +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``GET ///-/definition`` returns a saved query definition without executing it. + +.. _QueryUpdateView: + +Updating saved queries +~~~~~~~~~~~~~~~~~~~~~~ + +``POST ///-/update`` updates a saved query using a JSON body with an ``"update"`` object. + +.. _QueryDeleteView: + +Deleting saved queries +~~~~~~~~~~~~~~~~~~~~~~ + +``POST ///-/delete`` deletes a saved query. + .. _TableInsertView: Inserting rows diff --git a/tests/plugins/my_plugin_2.py b/tests/plugins/my_plugin_2.py index e3d3e760..864637a6 100644 --- a/tests/plugins/my_plugin_2.py +++ b/tests/plugins/my_plugin_2.py @@ -139,20 +139,6 @@ def startup(datasette): datasette._startup_catalog_databases = [ row["database_name"] for row in catalog_rows ] - for database in datasette.databases: - await datasette.add_query( - database, - "from_hook", - "select 1, 'null' as actor_id", - source="plugin", - ) - result = await datasette.get_database(database).execute("select 1 + 1") - await datasette.add_query( - database, - "from_async_hook", - "select {}".format(result.first()[0]), - source="plugin", - ) return inner diff --git a/tests/test_canned_queries.py b/tests/test_canned_queries.py index e06ad189..c46fd86f 100644 --- a/tests/test_canned_queries.py +++ b/tests/test_canned_queries.py @@ -254,10 +254,8 @@ def test_canned_query_permissions_on_database_page(canned_write_client): } assert query_names == { "add_name_specify_id_with_error_in_on_success_message_sql", - "from_hook", "update_name", "add_name_specify_id", - "from_async_hook", "canned_read", "add_name", } @@ -284,8 +282,6 @@ def test_canned_query_permissions_on_database_page(canned_write_client): }, {"name": "canned_read", "private": False}, {"name": "delete_name", "private": True}, - {"name": "from_async_hook", "private": False}, - {"name": "from_hook", "private": False}, {"name": "update_name", "private": False}, ] diff --git a/tests/test_html.py b/tests/test_html.py index efc1040d..e5f00e17 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -158,8 +158,6 @@ async def test_database_page(ds_client): queries_ul = soup.find("h2", string="Queries").find_next_sibling("ul") assert queries_ul is not None assert [ - ("/fixtures/from_async_hook", "from_async_hook"), - ("/fixtures/from_hook", "from_hook"), ("/fixtures/magic_parameters", "magic_parameters"), ("/fixtures/neighborhood_search#fragment-goes-here", "Search neighborhoods"), ("/fixtures/pragma_cache_size", "pragma_cache_size"), diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 04800ed3..22f294bb 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -622,7 +622,6 @@ def test_padlocks_on_database_page(cascade_app_client): assert ">123_starts_with_digits" in response.text assert ">Table With Space In Name 🔒" in response.text # Queries - assert ">from_async_hook 🔒" in response.text assert ">query_two" in response.text # Views assert ">paginated_view 🔒" in response.text diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b5a13ae5..f7adbd66 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -885,24 +885,61 @@ async def test_hook_startup_catalog_populated(ds_client): @pytest.mark.asyncio -async def test_plugin_startup_queries(ds_client): - queries = (await ds_client.get("/fixtures.json")).json()["queries"] +async def test_plugin_startup_can_add_queries(): + ds = Datasette(memory=True) + ds.add_memory_database("plugin_startup_queries", name="data") + + class AddQueriesPlugin: + __name__ = "AddQueriesPlugin" + + @hookimpl + def startup(self, datasette): + async def inner(): + result = await datasette.get_database("data").execute("select 1 + 1") + await datasette.add_query( + "data", + "from_startup", + "select {}".format(result.first()[0]), + source="plugin", + ) + + return inner + + ds.pm.register(AddQueriesPlugin(), name="add_queries_plugin") + try: + response = await ds.client.get("/data.json") + finally: + ds.pm.unregister(name="add_queries_plugin") + + queries = response.json()["queries"] queries_by_name = {q["name"]: q for q in queries} - assert queries_by_name["from_async_hook"]["sql"] == "select 2" - assert queries_by_name["from_async_hook"]["private"] is False - assert queries_by_name["from_hook"]["sql"] == "select 1, 'null' as actor_id" - assert queries_by_name["from_hook"]["private"] is False + assert queries_by_name["from_startup"]["sql"] == "select 2" + assert queries_by_name["from_startup"]["private"] is False @pytest.mark.asyncio -async def test_plugin_startup_query_from_hook(ds_client): - response = await ds_client.get("/fixtures/from_hook.json?_shape=array") - assert [{"1": 1, "actor_id": "null"}] == response.json() +async def test_plugin_startup_query_can_execute(): + ds = Datasette(memory=True) + ds.add_memory_database("plugin_startup_query_execute", name="data") + class AddQueryPlugin: + __name__ = "AddQueryPlugin" + + @hookimpl + def startup(self, datasette): + async def inner(): + await datasette.add_query( + "data", "from_startup", "select 2", source="plugin" + ) + + return inner + + ds.pm.register(AddQueryPlugin(), name="add_query_plugin") + try: + response = await ds.client.get("/data/from_startup.json?_shape=array") + finally: + ds.pm.unregister(name="add_query_plugin") -@pytest.mark.asyncio -async def test_plugin_startup_query_from_async_hook(ds_client): - response = await ds_client.get("/fixtures/from_async_hook.json?_shape=array") assert [{"2": 2}] == response.json() @@ -1514,9 +1551,9 @@ async def test_hook_top_query(ds_client): async def test_hook_top_canned_query(ds_client): try: pm.register(SlotPlugin(), name="SlotPlugin") - response = await ds_client.get("/fixtures/from_hook?z=xyz") + response = await ds_client.get("/fixtures/magic_parameters?z=xyz") assert response.status_code == 200 - assert "Xtop_query:fixtures:from_hook:xyz" in response.text + assert "Xtop_query:fixtures:magic_parameters:xyz" in response.text finally: pm.unregister(name="SlotPlugin")