Skip to content

Commit a23c2ae

Browse files
asg017simonw
andauthored
Introduce new /$DB/-/query endpoint, soft replaces /$DB?sql=... (#2363)
* Introduce new default /$DB/-/query endpoint * Fix a lot of tests * Update pyodide test to use query endpoint * Link to /fixtures/-/query in a few places * Documentation for QueryView --------- Co-authored-by: Simon Willison <[email protected]>
1 parent 56adfff commit a23c2ae

21 files changed

+148
-83
lines changed

datasette/app.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from .events import Event
3838
from .views import Context
3939
from .views.base import ureg
40-
from .views.database import database_download, DatabaseView, TableCreateView
40+
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
4141
from .views.index import IndexView
4242
from .views.special import (
4343
JsonDataView,
@@ -1578,6 +1578,10 @@ def add_route(view, regex):
15781578
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
15791579
)
15801580
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
1581+
add_route(
1582+
wrap_view(QueryView, self),
1583+
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
1584+
)
15811585
add_route(
15821586
wrap_view(table_view, self),
15831587
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",

datasette/templates/database.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ <h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
2121
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
2222

2323
{% if allow_execute_sql %}
24-
<form class="sql" action="{{ urls.database(database) }}" method="get">
24+
<form class="sql" action="{{ urls.database(database) }}/-/query" method="get">
2525
<h3>Custom SQL query</h3>
2626
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
2727
<p>
@@ -36,7 +36,7 @@ <h3>Custom SQL query</h3>
3636
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
3737
<ul class="bullets">
3838
{% for db_name in attached_databases %}
39-
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
39+
<li><strong>{{ db_name }}</strong> - <a href="{{ urls.database(db_name) }}/-/query?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
4040
{% endfor %}
4141
</ul>
4242
</div>

datasette/views/database.py

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ async def get(self, request, datasette):
5858

5959
sql = (request.args.get("sql") or "").strip()
6060
if sql:
61+
redirect_url = "/" + request.url_vars.get("database") + "/-/query"
62+
if request.url_vars.get("format"):
63+
redirect_url += "." + request.url_vars.get("format")
64+
redirect_url += "?" + request.query_string
65+
return Response.redirect(redirect_url)
6166
return await QueryView()(request, datasette)
6267

6368
if format_ not in ("html", "json"):
@@ -433,6 +438,8 @@ async def post(self, request, datasette):
433438
async def get(self, request, datasette):
434439
from datasette.app import TableNotFound
435440

441+
await datasette.refresh_schemas()
442+
436443
db = await datasette.resolve_database(request)
437444
database = db.name
438445

@@ -686,6 +693,7 @@ async def fetch_data_for_csv(request, _next=None):
686693
if allow_execute_sql and is_validated_sql and ":_" not in sql:
687694
edit_sql_url = (
688695
datasette.urls.database(database)
696+
+ "/-/query"
689697
+ "?"
690698
+ urlencode(
691699
{

docs/pages.rst

+15
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,21 @@ The following tables are hidden by default:
5555
- Tables relating to the inner workings of the SpatiaLite SQLite extension.
5656
- ``sqlite_stat`` tables used to store statistics used by the query optimizer.
5757

58+
.. _QueryView:
59+
60+
Queries
61+
=======
62+
63+
The ``/database-name/-/query`` page can be used to execute an arbitrary SQL query against that database, if the :ref:`permissions_execute_sql` permission is enabled. This query is passed as the ``?sql=`` query string parameter.
64+
65+
This means you can link directly to a query by constructing the following URL:
66+
67+
``/database-name/-/query?sql=SELECT+*+FROM+table_name``
68+
69+
Each configured :ref:`canned query <canned_queries>` has its own page, at ``/database-name/query-name``. Viewing this page will execute the query and display the results.
70+
71+
In both cases adding a ``.json`` extension to the URL will return the results as JSON.
72+
5873
.. _TableView:
5974

6075
Table

test-in-pyodide-with-shot-scraper.sh

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ async () => {
4040
import setuptools
4141
from datasette.app import Datasette
4242
ds = Datasette(memory=True, settings={'num_sql_threads': 0})
43-
(await ds.client.get('/_memory.json?sql=select+55+as+itworks&_shape=array')).text
43+
(await ds.client.get('/_memory/-/query.json?sql=select+55+as+itworks&_shape=array')).text
4444
\`);
4545
if (JSON.parse(output)[0].itworks != 55) {
4646
throw 'Got ' + output + ', expected itworks: 55';
4747
}
4848
return 'Test passed!';
4949
}
50-
"
50+
"

tests/plugins/my_plugin.py

+1
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ def query_actions(datasette, database, query_name, sql):
411411
return [
412412
{
413413
"href": datasette.urls.database(database)
414+
+ "/-/query"
414415
+ "?"
415416
+ urllib.parse.urlencode(
416417
{

tests/test_api.py

+27-11
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
623623
} == response.json
624624
# Try that SQL query
625625
response = app_client_no_files.get(
626-
"/_memory.json?sql=select+sqlite_version()&_shape=array"
626+
"/_memory/-/query.json?sql=select+sqlite_version()&_shape=array"
627627
)
628628
assert 1 == len(response.json)
629629
assert ["sqlite_version()"] == list(response.json[0].keys())
@@ -653,7 +653,7 @@ def test_database_page_for_database_with_dot_in_name(app_client_with_dot):
653653
@pytest.mark.asyncio
654654
async def test_custom_sql(ds_client):
655655
response = await ds_client.get(
656-
"/fixtures.json?sql=select+content+from+simple_primary_key"
656+
"/fixtures/-/query.json?sql=select+content+from+simple_primary_key",
657657
)
658658
data = response.json()
659659
assert data == {
@@ -670,7 +670,9 @@ async def test_custom_sql(ds_client):
670670

671671

672672
def test_sql_time_limit(app_client_shorter_time_limit):
673-
response = app_client_shorter_time_limit.get("/fixtures.json?sql=select+sleep(0.5)")
673+
response = app_client_shorter_time_limit.get(
674+
"/fixtures/-/query.json?sql=select+sleep(0.5)",
675+
)
674676
assert 400 == response.status
675677
assert response.json == {
676678
"ok": False,
@@ -691,16 +693,22 @@ def test_sql_time_limit(app_client_shorter_time_limit):
691693

692694
@pytest.mark.asyncio
693695
async def test_custom_sql_time_limit(ds_client):
694-
response = await ds_client.get("/fixtures.json?sql=select+sleep(0.01)")
696+
response = await ds_client.get(
697+
"/fixtures/-/query.json?sql=select+sleep(0.01)",
698+
)
695699
assert response.status_code == 200
696-
response = await ds_client.get("/fixtures.json?sql=select+sleep(0.01)&_timelimit=5")
700+
response = await ds_client.get(
701+
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5",
702+
)
697703
assert response.status_code == 400
698704
assert response.json()["title"] == "SQL Interrupted"
699705

700706

701707
@pytest.mark.asyncio
702708
async def test_invalid_custom_sql(ds_client):
703-
response = await ds_client.get("/fixtures.json?sql=.schema")
709+
response = await ds_client.get(
710+
"/fixtures/-/query.json?sql=.schema",
711+
)
704712
assert response.status_code == 400
705713
assert response.json()["ok"] is False
706714
assert "Statement must be a SELECT" == response.json()["error"]
@@ -883,9 +891,13 @@ async def test_json_columns(ds_client, extra_args, expected):
883891
select 1 as intval, "s" as strval, 0.5 as floatval,
884892
'{"foo": "bar"}' as jsonval
885893
"""
886-
path = "/fixtures.json?" + urllib.parse.urlencode({"sql": sql, "_shape": "array"})
894+
path = "/fixtures/-/query.json?" + urllib.parse.urlencode(
895+
{"sql": sql, "_shape": "array"}
896+
)
887897
path += extra_args
888-
response = await ds_client.get(path)
898+
response = await ds_client.get(
899+
path,
900+
)
889901
assert response.json() == expected
890902

891903

@@ -917,7 +929,7 @@ def test_config_force_https_urls():
917929
("/fixtures.json", 200),
918930
("/fixtures/no_primary_key.json", 200),
919931
# A 400 invalid SQL query should still have the header:
920-
("/fixtures.json?sql=select+blah", 400),
932+
("/fixtures/-/query.json?sql=select+blah", 400),
921933
# Write APIs
922934
("/fixtures/-/create", 405),
923935
("/fixtures/facetable/-/insert", 405),
@@ -930,7 +942,9 @@ def test_cors(
930942
path,
931943
status_code,
932944
):
933-
response = app_client_with_cors.get(path)
945+
response = app_client_with_cors.get(
946+
path,
947+
)
934948
assert response.status == status_code
935949
assert response.headers["Access-Control-Allow-Origin"] == "*"
936950
assert (
@@ -946,7 +960,9 @@ def test_cors(
946960
# should not have those headers - I'm using that fixture because
947961
# regular app_client doesn't have immutable fixtures.db which means
948962
# the test for /fixtures.db returns a 403 error
949-
response = app_client_two_attached_databases_one_immutable.get(path)
963+
response = app_client_two_attached_databases_one_immutable.get(
964+
path,
965+
)
950966
assert response.status == status_code
951967
assert "Access-Control-Allow-Origin" not in response.headers
952968
assert "Access-Control-Allow-Headers" not in response.headers

tests/test_api_write.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -637,15 +637,19 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
637637
# Should be a single row
638638
assert (
639639
await ds_write.client.get(
640-
"/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table)
640+
"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(
641+
table
642+
)
641643
)
642644
).json() == [1]
643645
# Now delete the row
644646
if delete_path is None:
645647
# Special case for that rowid table
646648
delete_path = (
647649
await ds_write.client.get(
648-
"/data.json?_shape=arrayfirst&sql=select+rowid+from+{}".format(table)
650+
"/data/-/query.json?_shape=arrayfirst&sql=select+rowid+from+{}".format(
651+
table
652+
)
649653
)
650654
).json()[0]
651655

@@ -663,7 +667,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
663667
assert event.pks == str(delete_path).split(",")
664668
assert (
665669
await ds_write.client.get(
666-
"/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table)
670+
"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(
671+
table
672+
)
667673
)
668674
).json() == [0]
669675

tests/test_canned_queries.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,7 @@ def test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_js
412412

413413
def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_client):
414414
response = magic_parameters_client.get(
415-
"/data.json?sql=select+:_header_host&_shape=array"
415+
"/data/-/query.json?sql=select+:_header_host&_shape=array"
416416
)
417417
assert 400 == response.status
418418
assert response.json["error"].startswith("You did not supply a value for binding")

tests/test_cli.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def test_plugin_s_overwrite():
250250
"--plugins-dir",
251251
plugins_dir,
252252
"--get",
253-
"/_memory.json?sql=select+prepare_connection_args()",
253+
"/_memory/-/query.json?sql=select+prepare_connection_args()",
254254
],
255255
)
256256
assert result.exit_code == 0, result.output
@@ -265,7 +265,7 @@ def test_plugin_s_overwrite():
265265
"--plugins-dir",
266266
plugins_dir,
267267
"--get",
268-
"/_memory.json?sql=select+prepare_connection_args()",
268+
"/_memory/-/query.json?sql=select+prepare_connection_args()",
269269
"-s",
270270
"plugins.name-of-plugin",
271271
"OVERRIDE",
@@ -295,7 +295,7 @@ def test_setting_default_allow_sql(default_allow_sql):
295295
"default_allow_sql",
296296
"on" if default_allow_sql else "off",
297297
"--get",
298-
"/_memory.json?sql=select+21&_shape=objects",
298+
"/_memory/-/query.json?sql=select+21&_shape=objects",
299299
],
300300
)
301301
if default_allow_sql:
@@ -309,7 +309,7 @@ def test_setting_default_allow_sql(default_allow_sql):
309309

310310
def test_sql_errors_logged_to_stderr():
311311
runner = CliRunner(mix_stderr=False)
312-
result = runner.invoke(cli, ["--get", "/_memory.json?sql=select+blah"])
312+
result = runner.invoke(cli, ["--get", "/_memory/-/query.json?sql=select+blah"])
313313
assert result.exit_code == 1
314314
assert "sql = 'select blah', params = {}: no such column: blah\n" in result.stderr
315315

tests/test_cli_serve_get.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def startup(datasette):
3131
"--plugins-dir",
3232
str(plugins_dir),
3333
"--get",
34-
"/_memory.json?sql=select+sqlite_version()",
34+
"/_memory/-/query.json?sql=select+sqlite_version()",
3535
],
3636
)
3737
assert result.exit_code == 0, result.output

tests/test_crossdb.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ def test_crossdb_join(app_client_two_attached_databases_crossdb_enabled):
2525
fixtures.searchable
2626
"""
2727
response = app_client.get(
28-
"/_memory.json?" + urllib.parse.urlencode({"sql": sql, "_shape": "array"})
28+
"/_memory/-/query.json?"
29+
+ urllib.parse.urlencode({"sql": sql, "_shape": "array"})
2930
)
3031
assert response.status == 200
3132
assert response.json == [
@@ -67,9 +68,10 @@ def test_crossdb_attached_database_list_display(
6768
):
6869
app_client = app_client_two_attached_databases_crossdb_enabled
6970
response = app_client.get("/_memory")
71+
response2 = app_client.get("/")
7072
for fragment in (
7173
"databases are attached to this connection",
7274
"<li><strong>fixtures</strong> - ",
73-
"<li><strong>extra database</strong> - ",
75+
'<li><strong>extra database</strong> - <a href="/extra+database/-/query?sql=',
7476
):
7577
assert fragment in response.text

tests/test_csv.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -146,22 +146,22 @@ async def test_table_csv_blob_columns(ds_client):
146146
@pytest.mark.asyncio
147147
async def test_custom_sql_csv_blob_columns(ds_client):
148148
response = await ds_client.get(
149-
"/fixtures.csv?sql=select+rowid,+data+from+binary_data"
149+
"/fixtures/-/query.csv?sql=select+rowid,+data+from+binary_data"
150150
)
151151
assert response.status_code == 200
152152
assert response.headers["content-type"] == "text/plain; charset=utf-8"
153153
assert response.text == (
154154
"rowid,data\r\n"
155-
'1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n'
156-
'2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n'
155+
'1,"http://localhost/fixtures/-/query.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n'
156+
'2,"http://localhost/fixtures/-/query.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n'
157157
"3,\r\n"
158158
)
159159

160160

161161
@pytest.mark.asyncio
162162
async def test_custom_sql_csv(ds_client):
163163
response = await ds_client.get(
164-
"/fixtures.csv?sql=select+content+from+simple_primary_key+limit+2"
164+
"/fixtures/-/query.csv?sql=select+content+from+simple_primary_key+limit+2"
165165
)
166166
assert response.status_code == 200
167167
assert response.headers["content-type"] == "text/plain; charset=utf-8"
@@ -182,7 +182,7 @@ async def test_table_csv_download(ds_client):
182182
@pytest.mark.asyncio
183183
async def test_csv_with_non_ascii_characters(ds_client):
184184
response = await ds_client.get(
185-
"/fixtures.csv?sql=select%0D%0A++%27%F0%9D%90%9C%F0%9D%90%A2%F0%9D%90%AD%F0%9D%90%A2%F0%9D%90%9E%F0%9D%90%AC%27+as+text%2C%0D%0A++1+as+number%0D%0Aunion%0D%0Aselect%0D%0A++%27bob%27+as+text%2C%0D%0A++2+as+number%0D%0Aorder+by%0D%0A++number"
185+
"/fixtures/-/query.csv?sql=select%0D%0A++%27%F0%9D%90%9C%F0%9D%90%A2%F0%9D%90%AD%F0%9D%90%A2%F0%9D%90%9E%F0%9D%90%AC%27+as+text%2C%0D%0A++1+as+number%0D%0Aunion%0D%0Aselect%0D%0A++%27bob%27+as+text%2C%0D%0A++2+as+number%0D%0Aorder+by%0D%0A++number"
186186
)
187187
assert response.status_code == 200
188188
assert response.headers["content-type"] == "text/plain; charset=utf-8"

0 commit comments

Comments
 (0)