diff --git a/datasette/static/app.css b/datasette/static/app.css
index da8ed2ab17..d2494a3432 100644
--- a/datasette/static/app.css
+++ b/datasette/static/app.css
@@ -447,3 +447,9 @@ svg.dropdown-menu-icon {
border-right: 5px solid transparent;
border-bottom: 5px solid #666;
}
+
+.canned-query-edit-sql {
+ padding-left: 0.5em;
+ position: relative;
+ top: 1px;
+}
diff --git a/datasette/templates/query.html b/datasette/templates/query.html
index c6574f31b8..be180f3367 100644
--- a/datasette/templates/query.html
+++ b/datasette/templates/query.html
@@ -54,6 +54,7 @@
Query parameters
{% if canned_write %}{% endif %}
+ {% if canned_query and edit_sql_url %}Edit SQL{% endif %}
diff --git a/datasette/views/database.py b/datasette/views/database.py
index c32ff92f4c..c06a6cea27 100644
--- a/datasette/views/database.py
+++ b/datasette/views/database.py
@@ -2,7 +2,7 @@
import itertools
import jinja2
import json
-from urllib.parse import parse_qsl
+from urllib.parse import parse_qsl, urlencode
from datasette.utils import (
check_visibility,
@@ -11,6 +11,7 @@
is_url,
path_with_added_args,
path_with_removed_args,
+ InvalidSql,
)
from datasette.utils.asgi import AsgiFileDownload, Response, Forbidden
from datasette.plugins import pm
@@ -301,6 +302,10 @@ async def extra_template():
),
)
+ allow_execute_sql = await self.ds.permission_allowed(
+ request.actor, "execute-sql", database, default=True
+ )
+
async def extra_template():
display_rows = []
for row in results.rows:
@@ -329,12 +334,38 @@ async def extra_template():
)
display_row.append(display_value)
display_rows.append(display_row)
+
+ # Show 'Edit SQL' button only if:
+ # - User is allowed to execute SQL
+ # - SQL is an approved SELECT statement
+ # - No magic parameters, so no :_ in the SQL string
+ edit_sql_url = None
+ is_validated_sql = False
+ try:
+ validate_sql_select(sql)
+ is_validated_sql = True
+ except InvalidSql:
+ pass
+ if allow_execute_sql and is_validated_sql and ":_" not in sql:
+ edit_sql_url = (
+ self.database_url(database)
+ + "?"
+ + urlencode(
+ {
+ **{
+ "sql": sql,
+ },
+ **named_parameter_values,
+ }
+ )
+ )
return {
"display_rows": display_rows,
"custom_sql": True,
"named_parameter_values": named_parameter_values,
"editable": editable,
"canned_query": canned_query,
+ "edit_sql_url": edit_sql_url,
"metadata": metadata,
"config": self.ds.config_dict(),
"request": request,
@@ -352,9 +383,7 @@ async def extra_template():
"columns": columns,
"query": {"sql": sql, "params": params},
"private": private,
- "allow_execute_sql": await self.ds.permission_allowed(
- request.actor, "execute-sql", database, default=True
- ),
+ "allow_execute_sql": allow_execute_sql,
},
extra_template,
templates,
diff --git a/tests/test_html.py b/tests/test_html.py
index 5691b6c45b..fb86d9d9e9 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -1403,3 +1403,48 @@ def test_base_url_config(base_url, path):
"href_or_src": href,
"element_parent": str(el.parent),
}
+
+
+@pytest.mark.parametrize(
+ "path,expected",
+ [
+ (
+ "/fixtures/neighborhood_search",
+ "/fixtures?sql=%0Aselect+neighborhood%2C+facet_cities.name%2C+state%0Afrom+facetable%0A++++join+facet_cities%0A++++++++on+facetable.city_id+%3D+facet_cities.id%0Awhere+neighborhood+like+%27%25%27+%7C%7C+%3Atext+%7C%7C+%27%25%27%0Aorder+by+neighborhood%3B%0A&text=",
+ ),
+ (
+ "/fixtures/neighborhood_search?text=ber",
+ "/fixtures?sql=%0Aselect+neighborhood%2C+facet_cities.name%2C+state%0Afrom+facetable%0A++++join+facet_cities%0A++++++++on+facetable.city_id+%3D+facet_cities.id%0Awhere+neighborhood+like+%27%25%27+%7C%7C+%3Atext+%7C%7C+%27%25%27%0Aorder+by+neighborhood%3B%0A&text=ber",
+ ),
+ ("/fixtures/pragma_cache_size", None),
+ (
+ "/fixtures/𝐜𝐢𝐭𝐢𝐞𝐬",
+ "/fixtures?sql=select+id%2C+name+from+facet_cities+order+by+id+limit+1%3B",
+ ),
+ ("/fixtures/magic_parameters", None),
+ ],
+)
+def test_edit_sql_link_on_canned_queries(app_client, path, expected):
+ response = app_client.get(path)
+ expected_link = 'Edit SQL'.format(
+ expected
+ )
+ if expected:
+ assert expected_link in response.text
+ else:
+ assert "Edit SQL" not in response.text
+
+
+@pytest.mark.parametrize("permission_allowed", [True, False])
+def test_edit_sql_link_not_shown_if_user_lacks_permission(permission_allowed):
+ with make_app_client(
+ metadata={
+ "allow_sql": None if permission_allowed else {"id": "not-you"},
+ "databases": {"fixtures": {"queries": {"simple": "select 1 + 1"}}},
+ }
+ ) as client:
+ response = client.get("/fixtures/simple")
+ if permission_allowed:
+ assert "Edit SQL" in response.text
+ else:
+ assert "Edit SQL" not in response.text