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