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 <swillison@gmail.com>
This commit is contained in:
Alex Garcia 2024-07-15 10:33:51 -07:00 committed by GitHub
commit a23c2aee00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 148 additions and 83 deletions

View file

@ -159,7 +159,7 @@ async def test_database_page(ds_client):
@pytest.mark.asyncio
async def test_invalid_custom_sql(ds_client):
response = await ds_client.get("/fixtures?sql=.schema")
response = await ds_client.get("/fixtures/-/query?sql=.schema")
assert response.status_code == 400
assert "Statement must be a SELECT" in response.text
@ -167,7 +167,7 @@ async def test_invalid_custom_sql(ds_client):
@pytest.mark.asyncio
async def test_disallowed_custom_sql_pragma(ds_client):
response = await ds_client.get(
"/fixtures?sql=SELECT+*+FROM+pragma_not_on_allow_list('idx52')"
"/fixtures/-/query?sql=SELECT+*+FROM+pragma_not_on_allow_list('idx52')"
)
assert response.status_code == 400
pragmas = ", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas)
@ -180,7 +180,9 @@ async def test_disallowed_custom_sql_pragma(ds_client):
def test_sql_time_limit(app_client_shorter_time_limit):
response = app_client_shorter_time_limit.get("/fixtures?sql=select+sleep(0.5)")
response = app_client_shorter_time_limit.get(
"/fixtures/-/query?sql=select+sleep(0.5)"
)
assert 400 == response.status
expected_html_fragments = [
"""
@ -207,7 +209,7 @@ def test_row_page_does_not_truncate():
def test_query_page_truncates():
with make_app_client(settings={"truncate_cells_html": 5}) as client:
response = client.get(
"/fixtures?"
"/fixtures/-/query?"
+ urllib.parse.urlencode(
{
"sql": "select 'this is longer than 5' as a, 'https://example.com/' as b"
@ -229,7 +231,7 @@ def test_query_page_truncates():
[
("/", ["index"]),
("/fixtures", ["db", "db-fixtures"]),
("/fixtures?sql=select+1", ["query", "db-fixtures"]),
("/fixtures/-/query?sql=select+1", ["query", "db-fixtures"]),
(
"/fixtures/simple_primary_key",
["table", "db-fixtures", "table-simple_primary_key"],
@ -296,21 +298,24 @@ async def test_row_json_export_link(ds_client):
@pytest.mark.asyncio
async def test_query_json_csv_export_links(ds_client):
response = await ds_client.get("/fixtures?sql=select+1")
response = await ds_client.get("/fixtures/-/query?sql=select+1")
assert response.status_code == 200
assert '<a href="/fixtures.json?sql=select+1">json</a>' in response.text
assert '<a href="/fixtures.csv?sql=select+1&amp;_size=max">CSV</a>' in response.text
assert '<a href="/fixtures/-/query.json?sql=select+1">json</a>' in response.text
assert (
'<a href="/fixtures/-/query.csv?sql=select+1&amp;_size=max">CSV</a>'
in response.text
)
@pytest.mark.asyncio
async def test_query_parameter_form_fields(ds_client):
response = await ds_client.get("/fixtures?sql=select+:name")
response = await ds_client.get("/fixtures/-/query?sql=select+:name")
assert response.status_code == 200
assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="">'
in response.text
)
response2 = await ds_client.get("/fixtures?sql=select+:name&name=hello")
response2 = await ds_client.get("/fixtures/-/query?sql=select+:name&name=hello")
assert response2.status_code == 200
assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello">'
@ -453,7 +458,9 @@ async def test_database_metadata(ds_client):
@pytest.mark.asyncio
async def test_database_metadata_with_custom_sql(ds_client):
response = await ds_client.get("/fixtures?sql=select+*+from+simple_primary_key")
response = await ds_client.get(
"/fixtures/-/query?sql=select+*+from+simple_primary_key"
)
assert response.status_code == 200
soup = Soup(response.text, "html.parser")
# Page title should be the default
@ -591,7 +598,7 @@ async def test_canned_query_with_custom_metadata(ds_client):
@pytest.mark.asyncio
async def test_urlify_custom_queries(ds_client):
path = "/fixtures?" + urllib.parse.urlencode(
path = "/fixtures/-/query?" + urllib.parse.urlencode(
{"sql": "select ('https://twitter.com/' || 'simonw') as user_url;"}
)
response = await ds_client.get(path)
@ -609,7 +616,7 @@ async def test_urlify_custom_queries(ds_client):
@pytest.mark.asyncio
async def test_show_hide_sql_query(ds_client):
path = "/fixtures?" + urllib.parse.urlencode(
path = "/fixtures/-/query?" + urllib.parse.urlencode(
{"sql": "select ('https://twitter.com/' || 'simonw') as user_url;"}
)
response = await ds_client.get(path)
@ -696,15 +703,15 @@ def test_canned_query_show_hide_metadata_option(
@pytest.mark.asyncio
async def test_binary_data_display_in_query(ds_client):
response = await ds_client.get("/fixtures?sql=select+*+from+binary_data")
response = await ds_client.get("/fixtures/-/query?sql=select+*+from+binary_data")
assert response.status_code == 200
table = Soup(response.content, "html.parser").find("table")
expected_tds = [
[
'<td class="col-data"><a class="blob-download" href="/fixtures.blob?sql=select+*+from+binary_data&amp;_blob_column=data&amp;_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d">&lt;Binary:\xa07\xa0bytes&gt;</a></td>'
'<td class="col-data"><a class="blob-download" href="/fixtures/-/query.blob?sql=select+*+from+binary_data&amp;_blob_column=data&amp;_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d">&lt;Binary:\xa07\xa0bytes&gt;</a></td>'
],
[
'<td class="col-data"><a class="blob-download" href="/fixtures.blob?sql=select+*+from+binary_data&amp;_blob_column=data&amp;_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724">&lt;Binary:\xa07\xa0bytes&gt;</a></td>'
'<td class="col-data"><a class="blob-download" href="/fixtures/-/query.blob?sql=select+*+from+binary_data&amp;_blob_column=data&amp;_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724">&lt;Binary:\xa07\xa0bytes&gt;</a></td>'
],
['<td class="col-data">\xa0</td>'],
]
@ -719,7 +726,7 @@ async def test_binary_data_display_in_query(ds_client):
[
("/fixtures/binary_data/1.blob?_blob_column=data", "binary_data-1-data.blob"),
(
"/fixtures.blob?sql=select+*+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d",
"/fixtures/-/query.blob?sql=select+*+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d",
"data-f30889.blob",
),
],
@ -758,7 +765,7 @@ async def test_blob_download_invalid_messages(ds_client, path, expected_message)
@pytest.mark.parametrize(
"path",
[
"/fixtures?sql=select+*+from+[123_starts_with_digits]",
"/fixtures/-/query?sql=select+*+from+[123_starts_with_digits]",
"/fixtures/123_starts_with_digits",
],
)
@ -771,7 +778,7 @@ async def test_zero_results(ds_client, path):
@pytest.mark.asyncio
async def test_query_error(ds_client):
response = await ds_client.get("/fixtures?sql=select+*+from+notatable")
response = await ds_client.get("/fixtures/-/query?sql=select+*+from+notatable")
html = response.text
assert '<p class="message-error">no such table: notatable</p>' in html
assert '<textarea id="sql-editor" name="sql" style="height: 3em' in html
@ -811,7 +818,7 @@ def test_debug_context_includes_extra_template_vars():
"/fixtures/paginated_view",
"/fixtures/facetable",
"/fixtures/facetable?_facet=state",
"/fixtures?sql=select+1",
"/fixtures/-/query?sql=select+1",
],
)
@pytest.mark.parametrize("use_prefix", (True, False))
@ -879,17 +886,17 @@ def test_base_url_affects_metadata_extra_css_urls(app_client_base_url_prefix):
[
(
"/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&amp;text=",
"/fixtures/-/query?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&amp;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&amp;text=ber",
"/fixtures/-/query?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&amp;text=ber",
),
("/fixtures/pragma_cache_size", None),
(
# /fixtures/𝐜𝐢𝐭𝐢𝐞𝐬
"/fixtures/~F0~9D~90~9C~F0~9D~90~A2~F0~9D~90~AD~F0~9D~90~A2~F0~9D~90~9E~F0~9D~90~AC",
"/fixtures?sql=select+id%2C+name+from+facet_cities+order+by+id+limit+1%3B",
"/fixtures/-/query?sql=select+id%2C+name+from+facet_cities+order+by+id+limit+1%3B",
),
("/fixtures/magic_parameters", None),
],
@ -960,7 +967,7 @@ async def test_navigation_menu_links(
@pytest.mark.asyncio
async def test_trace_correctly_escaped(ds_client):
response = await ds_client.get("/fixtures?sql=select+'<h1>Hello'&_trace=1")
response = await ds_client.get("/fixtures/-/query?sql=select+'<h1>Hello'&_trace=1")
assert "select '<h1>Hello" not in response.text
assert "select &#39;&lt;h1&gt;Hello" in response.text
@ -989,8 +996,8 @@ async def test_trace_correctly_escaped(ds_client):
),
# Custom query page
(
"/fixtures?sql=select+*+from+facetable",
"http://localhost/fixtures.json?sql=select+*+from+facetable",
"/fixtures/-/query?sql=select+*+from+facetable",
"http://localhost/fixtures/-/query.json?sql=select+*+from+facetable",
),
# Canned query page
(