From 55225579557e51f5611682ff772051c7c0626d40 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 27 Feb 2024 21:47:51 -0800 Subject: [PATCH 1/2] New query_actions plugin hook, refs #2283 --- datasette/hookspecs.py | 5 ++++ datasette/templates/query.html | 27 ++++++++++++++++++ datasette/views/database.py | 23 +++++++++++++++ docs/plugin_hooks.rst | 52 ++++++++++++++++++++++++++++++++++ tests/fixtures.py | 1 + tests/plugins/my_plugin.py | 18 ++++++++++++ tests/test_plugins.py | 25 ++++++++++++++++ 7 files changed, 151 insertions(+) diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py index b473f39801..1141ca756e 100644 --- a/datasette/hookspecs.py +++ b/datasette/hookspecs.py @@ -145,6 +145,11 @@ def table_actions(datasette, actor, database, table, request): """Links for the table actions menu""" +@hookspec +def query_actions(datasette, actor, database, query_name, request, sql, params): + """Links for the query and canned query actions menu""" + + @hookspec def database_actions(datasette, actor, database, request): """Links for the database actions menu""" diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 1815e592ca..a483575b7b 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -29,6 +29,33 @@ {% endif %}

{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}

+{% set links = query_actions() %}{% if links %} +
+ +
+{% endif %} + {% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 56fc6f8c1d..851ae21fa1 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -9,6 +9,7 @@ import re import sqlite_utils import textwrap +from typing import List from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.database import QueryInterrupted @@ -256,6 +257,11 @@ class QueryContext: top_canned_query: callable = field( metadata={"help": "Callable to render the top_canned_query slot"} ) + query_actions: callable = field( + metadata={ + "help": "Callable returning a list of links for the query action menu" + } + ) async def get_tables(datasette, request, db): @@ -694,6 +700,22 @@ async def fetch_data_for_csv(request, _next=None): ) ) + async def query_actions(): + query_actions = [] + for hook in pm.hook.query_actions( + datasette=datasette, + actor=request.actor, + database=database, + query_name=canned_query["name"] if canned_query else None, + request=request, + sql=sql, + params=params, + ): + extra_links = await await_me_maybe(hook) + if extra_links: + query_actions.extend(extra_links) + return query_actions + r = Response.html( await datasette.render_template( template, @@ -749,6 +771,7 @@ async def fetch_data_for_csv(request, _next=None): database=database, query_name=canned_query["name"] if canned_query else None, ), + query_actions=query_actions, ), request=request, view_name="database", diff --git a/docs/plugin_hooks.rst b/docs/plugin_hooks.rst index 5372ea5e52..3ada41e251 100644 --- a/docs/plugin_hooks.rst +++ b/docs/plugin_hooks.rst @@ -1520,6 +1520,58 @@ This example adds a new table action if the signed in user is ``"root"``: Example: `datasette-graphql `_ +.. _plugin_hook_query_actions: + +query_actions(datasette, actor, database, query_name, request, sql, params) +--------------------------------------------------------------------------- + +``datasette`` - :ref:`internals_datasette` + You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries. + +``actor`` - dictionary or None + The currently authenticated :ref:`actor `. + +``database`` - string + The name of the database. + +``query_name`` - string or None + The name of the canned query, or ``None`` if this is an arbitrary SQL query. + +``request`` - :ref:`internals_request` + The current HTTP request. + +``sql`` - string + The SQL query being executed + +``params`` - dictionary + The parameters passed to the SQL query, if any. + +This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the canned query and arbitrary SQL query pages. + +This example adds a new query action linking to a page for explaining a query: + +.. code-block:: python + + from datasette import hookimpl + import urllib + + + @hookimpl + def query_actions(datasette, database, sql): + return [ + { + "href": datasette.urls.database(database) + + "/-/explain?" + + urllib.parse.urlencode( + { + "sql": sql, + } + ), + "label": "Explain this query", + }, + ] + + .. _plugin_hook_database_actions: database_actions(datasette, actor, database, request) diff --git a/tests/fixtures.py b/tests/fixtures.py index bb979d79df..c3c77fce27 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -46,6 +46,7 @@ "permission_allowed", "prepare_connection", "prepare_jinja2_environment", + "query_actions", "register_facet_classes", "register_magic_parameters", "register_permissions", diff --git a/tests/plugins/my_plugin.py b/tests/plugins/my_plugin.py index 9d1f86bce5..f96441cba2 100644 --- a/tests/plugins/my_plugin.py +++ b/tests/plugins/my_plugin.py @@ -7,6 +7,7 @@ import base64 import pint import json +import urllib ureg = pint.UnitRegistry() @@ -390,6 +391,23 @@ def table_actions(datasette, database, table, actor): ] +@hookimpl +def query_actions(datasette, database, query_name, sql): + args = { + "sql": sql, + } + if query_name: + args["query_name"] = query_name + return [ + { + "href": datasette.urls.database(database) + + "/-/explain?" + + urllib.parse.urlencode(args), + "label": "Explain this query", + }, + ] + + @hookimpl def database_actions(datasette, database, actor, request): if actor: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 40d01c71b6..86208371b8 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -945,6 +945,31 @@ def get_table_actions_links(html): ] +@pytest.mark.asyncio +@pytest.mark.parametrize( + "path,expected_url", + ( + ("/fixtures?sql=select+1", "/fixtures/-/explain?sql=select+1"), + ( + "/fixtures/pragma_cache_size", + "/fixtures/-/explain?sql=PRAGMA+cache_size%3B&query_name=pragma_cache_size", + ), + ), +) +async def test_hook_query_actions(ds_client, path, expected_url): + def get_table_actions_links(html): + soup = Soup(html, "html.parser") + details = soup.find("details", {"class": "actions-menu-links"}) + if details is None: + return [] + return [{"label": a.text, "href": a["href"]} for a in details.select("a")] + + response = await ds_client.get(path) + assert response.status_code == 200 + links = get_table_actions_links(response.text) + assert links == [{"label": "Explain this query", "href": expected_url}] + + @pytest.mark.asyncio async def test_hook_database_actions(ds_client): def get_table_actions_links(html): From e0930b402e38618faa1a469beb298bcaf707fa61 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 27 Feb 2024 21:54:03 -0800 Subject: [PATCH 2/2] Correct title for icon --- datasette/templates/query.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datasette/templates/query.html b/datasette/templates/query.html index a483575b7b..b599177294 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -35,7 +35,7 @@

{{
- Database actions + Query actions