diff --git a/datasette/hookspecs.py b/datasette/hookspecs.py
index b473f398..1141ca75 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 1815e592..b5991772 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 56fc6f8c..851ae21f 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -9,6 +9,7 @@ import os
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 @@ class QueryView(View):
)
)
+ 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 @@ class QueryView(View):
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 5372ea5e..3ada41e2 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 bb979d79..c3c77fce 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -46,6 +46,7 @@ EXPECTED_PLUGINS = [
"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 9d1f86bc..f96441cb 100644
--- a/tests/plugins/my_plugin.py
+++ b/tests/plugins/my_plugin.py
@@ -7,6 +7,7 @@ from datasette.utils.asgi import asgi_send_json, Response
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 40d01c71..86208371 100644
--- a/tests/test_plugins.py
+++ b/tests/test_plugins.py
@@ -945,6 +945,31 @@ async def test_hook_table_actions(ds_client, table_or_view):
]
+@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):