Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

query_actions plugin hook, refs #2283 #2288

Merged
merged 2 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
27 changes: 27 additions & 0 deletions datasette/templates/query.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,33 @@
{% endif %}

<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
{% set links = query_actions() %}{% if links %}
<div class="page-action-menu">
<details class="actions-menu-links details-menu">
<summary>
<div class="icon-text">
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<title id="actions-menu-links-title">Query actions</title>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>Query actions</span>
</div>
</summary>
<div class="dropdown-menu">
<div class="hook"></div>
{% if links %}
<ul>
{% for link in links %}
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
{% endfor %}
</ul>
{% endif %}
</div>
</details>
</div>
{% endif %}


{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}

Expand Down
23 changes: 23 additions & 0 deletions datasette/views/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions docs/plugin_hooks.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,58 @@ This example adds a new table action if the signed in user is ``"root"``:

Example: `datasette-graphql <https://datasette.io/plugins/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 <authentication_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)
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"permission_allowed",
"prepare_connection",
"prepare_jinja2_environment",
"query_actions",
"register_facet_classes",
"register_magic_parameters",
"register_permissions",
Expand Down
18 changes: 18 additions & 0 deletions tests/plugins/my_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import base64
import pint
import json
import urllib

ureg = pint.UnitRegistry()

Expand Down Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading