kopia lustrzana https://github.com/simonw/datasette
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>pull/2367/head
rodzic
56adfff8d2
commit
a23c2aee00
|
@ -37,7 +37,7 @@ from jinja2.exceptions import TemplateNotFound
|
|||
from .events import Event
|
||||
from .views import Context
|
||||
from .views.base import ureg
|
||||
from .views.database import database_download, DatabaseView, TableCreateView
|
||||
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
||||
from .views.index import IndexView
|
||||
from .views.special import (
|
||||
JsonDataView,
|
||||
|
@ -1578,6 +1578,10 @@ class Datasette:
|
|||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||
add_route(
|
||||
wrap_view(QueryView, self),
|
||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(table_view, self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||
|
||||
{% if allow_execute_sql %}
|
||||
<form class="sql" action="{{ urls.database(database) }}" method="get">
|
||||
<form class="sql" action="{{ urls.database(database) }}/-/query" method="get">
|
||||
<h3>Custom SQL query</h3>
|
||||
<p><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
|
||||
<p>
|
||||
|
@ -36,7 +36,7 @@
|
|||
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
|
||||
<ul class="bullets">
|
||||
{% for db_name in attached_databases %}
|
||||
<li><strong>{{ db_name }}</strong> - <a href="?sql=select+*+from+[{{ db_name }}].sqlite_master+where+type='table'">tables</a></li>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -58,6 +58,11 @@ class DatabaseView(View):
|
|||
|
||||
sql = (request.args.get("sql") or "").strip()
|
||||
if sql:
|
||||
redirect_url = "/" + request.url_vars.get("database") + "/-/query"
|
||||
if request.url_vars.get("format"):
|
||||
redirect_url += "." + request.url_vars.get("format")
|
||||
redirect_url += "?" + request.query_string
|
||||
return Response.redirect(redirect_url)
|
||||
return await QueryView()(request, datasette)
|
||||
|
||||
if format_ not in ("html", "json"):
|
||||
|
@ -433,6 +438,8 @@ class QueryView(View):
|
|||
async def get(self, request, datasette):
|
||||
from datasette.app import TableNotFound
|
||||
|
||||
await datasette.refresh_schemas()
|
||||
|
||||
db = await datasette.resolve_database(request)
|
||||
database = db.name
|
||||
|
||||
|
@ -686,6 +693,7 @@ class QueryView(View):
|
|||
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
||||
edit_sql_url = (
|
||||
datasette.urls.database(database)
|
||||
+ "/-/query"
|
||||
+ "?"
|
||||
+ urlencode(
|
||||
{
|
||||
|
|
|
@ -55,6 +55,21 @@ The following tables are hidden by default:
|
|||
- Tables relating to the inner workings of the SpatiaLite SQLite extension.
|
||||
- ``sqlite_stat`` tables used to store statistics used by the query optimizer.
|
||||
|
||||
.. _QueryView:
|
||||
|
||||
Queries
|
||||
=======
|
||||
|
||||
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.
|
||||
|
||||
This means you can link directly to a query by constructing the following URL:
|
||||
|
||||
``/database-name/-/query?sql=SELECT+*+FROM+table_name``
|
||||
|
||||
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.
|
||||
|
||||
In both cases adding a ``.json`` extension to the URL will return the results as JSON.
|
||||
|
||||
.. _TableView:
|
||||
|
||||
Table
|
||||
|
|
|
@ -40,11 +40,11 @@ async () => {
|
|||
import setuptools
|
||||
from datasette.app import Datasette
|
||||
ds = Datasette(memory=True, settings={'num_sql_threads': 0})
|
||||
(await ds.client.get('/_memory.json?sql=select+55+as+itworks&_shape=array')).text
|
||||
(await ds.client.get('/_memory/-/query.json?sql=select+55+as+itworks&_shape=array')).text
|
||||
\`);
|
||||
if (JSON.parse(output)[0].itworks != 55) {
|
||||
throw 'Got ' + output + ', expected itworks: 55';
|
||||
}
|
||||
return 'Test passed!';
|
||||
}
|
||||
"
|
||||
"
|
||||
|
|
|
@ -411,6 +411,7 @@ def query_actions(datasette, database, query_name, sql):
|
|||
return [
|
||||
{
|
||||
"href": datasette.urls.database(database)
|
||||
+ "/-/query"
|
||||
+ "?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
|
|
|
@ -623,7 +623,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
|
|||
} == response.json
|
||||
# Try that SQL query
|
||||
response = app_client_no_files.get(
|
||||
"/_memory.json?sql=select+sqlite_version()&_shape=array"
|
||||
"/_memory/-/query.json?sql=select+sqlite_version()&_shape=array"
|
||||
)
|
||||
assert 1 == len(response.json)
|
||||
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):
|
|||
@pytest.mark.asyncio
|
||||
async def test_custom_sql(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.json?sql=select+content+from+simple_primary_key"
|
||||
"/fixtures/-/query.json?sql=select+content+from+simple_primary_key",
|
||||
)
|
||||
data = response.json()
|
||||
assert data == {
|
||||
|
@ -670,7 +670,9 @@ async def test_custom_sql(ds_client):
|
|||
|
||||
|
||||
def test_sql_time_limit(app_client_shorter_time_limit):
|
||||
response = app_client_shorter_time_limit.get("/fixtures.json?sql=select+sleep(0.5)")
|
||||
response = app_client_shorter_time_limit.get(
|
||||
"/fixtures/-/query.json?sql=select+sleep(0.5)",
|
||||
)
|
||||
assert 400 == response.status
|
||||
assert response.json == {
|
||||
"ok": False,
|
||||
|
@ -691,16 +693,22 @@ def test_sql_time_limit(app_client_shorter_time_limit):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_sql_time_limit(ds_client):
|
||||
response = await ds_client.get("/fixtures.json?sql=select+sleep(0.01)")
|
||||
response = await ds_client.get(
|
||||
"/fixtures/-/query.json?sql=select+sleep(0.01)",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response = await ds_client.get("/fixtures.json?sql=select+sleep(0.01)&_timelimit=5")
|
||||
response = await ds_client.get(
|
||||
"/fixtures/-/query.json?sql=select+sleep(0.01)&_timelimit=5",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["title"] == "SQL Interrupted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_custom_sql(ds_client):
|
||||
response = await ds_client.get("/fixtures.json?sql=.schema")
|
||||
response = await ds_client.get(
|
||||
"/fixtures/-/query.json?sql=.schema",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["ok"] is False
|
||||
assert "Statement must be a SELECT" == response.json()["error"]
|
||||
|
@ -883,9 +891,13 @@ async def test_json_columns(ds_client, extra_args, expected):
|
|||
select 1 as intval, "s" as strval, 0.5 as floatval,
|
||||
'{"foo": "bar"}' as jsonval
|
||||
"""
|
||||
path = "/fixtures.json?" + urllib.parse.urlencode({"sql": sql, "_shape": "array"})
|
||||
path = "/fixtures/-/query.json?" + urllib.parse.urlencode(
|
||||
{"sql": sql, "_shape": "array"}
|
||||
)
|
||||
path += extra_args
|
||||
response = await ds_client.get(path)
|
||||
response = await ds_client.get(
|
||||
path,
|
||||
)
|
||||
assert response.json() == expected
|
||||
|
||||
|
||||
|
@ -917,7 +929,7 @@ def test_config_force_https_urls():
|
|||
("/fixtures.json", 200),
|
||||
("/fixtures/no_primary_key.json", 200),
|
||||
# A 400 invalid SQL query should still have the header:
|
||||
("/fixtures.json?sql=select+blah", 400),
|
||||
("/fixtures/-/query.json?sql=select+blah", 400),
|
||||
# Write APIs
|
||||
("/fixtures/-/create", 405),
|
||||
("/fixtures/facetable/-/insert", 405),
|
||||
|
@ -930,7 +942,9 @@ def test_cors(
|
|||
path,
|
||||
status_code,
|
||||
):
|
||||
response = app_client_with_cors.get(path)
|
||||
response = app_client_with_cors.get(
|
||||
path,
|
||||
)
|
||||
assert response.status == status_code
|
||||
assert response.headers["Access-Control-Allow-Origin"] == "*"
|
||||
assert (
|
||||
|
@ -946,7 +960,9 @@ def test_cors(
|
|||
# should not have those headers - I'm using that fixture because
|
||||
# regular app_client doesn't have immutable fixtures.db which means
|
||||
# the test for /fixtures.db returns a 403 error
|
||||
response = app_client_two_attached_databases_one_immutable.get(path)
|
||||
response = app_client_two_attached_databases_one_immutable.get(
|
||||
path,
|
||||
)
|
||||
assert response.status == status_code
|
||||
assert "Access-Control-Allow-Origin" not in response.headers
|
||||
assert "Access-Control-Allow-Headers" not in response.headers
|
||||
|
|
|
@ -637,7 +637,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
|
|||
# Should be a single row
|
||||
assert (
|
||||
await ds_write.client.get(
|
||||
"/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table)
|
||||
"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(
|
||||
table
|
||||
)
|
||||
)
|
||||
).json() == [1]
|
||||
# Now delete the row
|
||||
|
@ -645,7 +647,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
|
|||
# Special case for that rowid table
|
||||
delete_path = (
|
||||
await ds_write.client.get(
|
||||
"/data.json?_shape=arrayfirst&sql=select+rowid+from+{}".format(table)
|
||||
"/data/-/query.json?_shape=arrayfirst&sql=select+rowid+from+{}".format(
|
||||
table
|
||||
)
|
||||
)
|
||||
).json()[0]
|
||||
|
||||
|
@ -663,7 +667,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
|
|||
assert event.pks == str(delete_path).split(",")
|
||||
assert (
|
||||
await ds_write.client.get(
|
||||
"/data.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(table)
|
||||
"/data/-/query.json?_shape=arrayfirst&sql=select+count(*)+from+{}".format(
|
||||
table
|
||||
)
|
||||
)
|
||||
).json() == [0]
|
||||
|
||||
|
|
|
@ -412,7 +412,7 @@ def test_magic_parameters_csrf_json(magic_parameters_client, use_csrf, return_js
|
|||
|
||||
def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_client):
|
||||
response = magic_parameters_client.get(
|
||||
"/data.json?sql=select+:_header_host&_shape=array"
|
||||
"/data/-/query.json?sql=select+:_header_host&_shape=array"
|
||||
)
|
||||
assert 400 == response.status
|
||||
assert response.json["error"].startswith("You did not supply a value for binding")
|
||||
|
|
|
@ -250,7 +250,7 @@ def test_plugin_s_overwrite():
|
|||
"--plugins-dir",
|
||||
plugins_dir,
|
||||
"--get",
|
||||
"/_memory.json?sql=select+prepare_connection_args()",
|
||||
"/_memory/-/query.json?sql=select+prepare_connection_args()",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
@ -265,7 +265,7 @@ def test_plugin_s_overwrite():
|
|||
"--plugins-dir",
|
||||
plugins_dir,
|
||||
"--get",
|
||||
"/_memory.json?sql=select+prepare_connection_args()",
|
||||
"/_memory/-/query.json?sql=select+prepare_connection_args()",
|
||||
"-s",
|
||||
"plugins.name-of-plugin",
|
||||
"OVERRIDE",
|
||||
|
@ -295,7 +295,7 @@ def test_setting_default_allow_sql(default_allow_sql):
|
|||
"default_allow_sql",
|
||||
"on" if default_allow_sql else "off",
|
||||
"--get",
|
||||
"/_memory.json?sql=select+21&_shape=objects",
|
||||
"/_memory/-/query.json?sql=select+21&_shape=objects",
|
||||
],
|
||||
)
|
||||
if default_allow_sql:
|
||||
|
@ -309,7 +309,7 @@ def test_setting_default_allow_sql(default_allow_sql):
|
|||
|
||||
def test_sql_errors_logged_to_stderr():
|
||||
runner = CliRunner(mix_stderr=False)
|
||||
result = runner.invoke(cli, ["--get", "/_memory.json?sql=select+blah"])
|
||||
result = runner.invoke(cli, ["--get", "/_memory/-/query.json?sql=select+blah"])
|
||||
assert result.exit_code == 1
|
||||
assert "sql = 'select blah', params = {}: no such column: blah\n" in result.stderr
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ def test_serve_with_get(tmp_path_factory):
|
|||
"--plugins-dir",
|
||||
str(plugins_dir),
|
||||
"--get",
|
||||
"/_memory.json?sql=select+sqlite_version()",
|
||||
"/_memory/-/query.json?sql=select+sqlite_version()",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
|
|
@ -25,7 +25,8 @@ def test_crossdb_join(app_client_two_attached_databases_crossdb_enabled):
|
|||
fixtures.searchable
|
||||
"""
|
||||
response = app_client.get(
|
||||
"/_memory.json?" + urllib.parse.urlencode({"sql": sql, "_shape": "array"})
|
||||
"/_memory/-/query.json?"
|
||||
+ urllib.parse.urlencode({"sql": sql, "_shape": "array"})
|
||||
)
|
||||
assert response.status == 200
|
||||
assert response.json == [
|
||||
|
@ -67,9 +68,10 @@ def test_crossdb_attached_database_list_display(
|
|||
):
|
||||
app_client = app_client_two_attached_databases_crossdb_enabled
|
||||
response = app_client.get("/_memory")
|
||||
response2 = app_client.get("/")
|
||||
for fragment in (
|
||||
"databases are attached to this connection",
|
||||
"<li><strong>fixtures</strong> - ",
|
||||
"<li><strong>extra database</strong> - ",
|
||||
'<li><strong>extra database</strong> - <a href="/extra+database/-/query?sql=',
|
||||
):
|
||||
assert fragment in response.text
|
||||
|
|
|
@ -146,14 +146,14 @@ async def test_table_csv_blob_columns(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_custom_sql_csv_blob_columns(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.csv?sql=select+rowid,+data+from+binary_data"
|
||||
"/fixtures/-/query.csv?sql=select+rowid,+data+from+binary_data"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/plain; charset=utf-8"
|
||||
assert response.text == (
|
||||
"rowid,data\r\n"
|
||||
'1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n'
|
||||
'2,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n'
|
||||
'1,"http://localhost/fixtures/-/query.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\r\n'
|
||||
'2,"http://localhost/fixtures/-/query.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\r\n'
|
||||
"3,\r\n"
|
||||
)
|
||||
|
||||
|
@ -161,7 +161,7 @@ async def test_custom_sql_csv_blob_columns(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_custom_sql_csv(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.csv?sql=select+content+from+simple_primary_key+limit+2"
|
||||
"/fixtures/-/query.csv?sql=select+content+from+simple_primary_key+limit+2"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/plain; charset=utf-8"
|
||||
|
@ -182,7 +182,7 @@ async def test_table_csv_download(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_csv_with_non_ascii_characters(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/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"
|
||||
"/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"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/plain; charset=utf-8"
|
||||
|
|
|
@ -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&_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&_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&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"><Binary:\xa07\xa0bytes></a></td>'
|
||||
'<td class="col-data"><a class="blob-download" href="/fixtures/-/query.blob?sql=select+*+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"><Binary:\xa07\xa0bytes></a></td>'
|
||||
],
|
||||
[
|
||||
'<td class="col-data"><a class="blob-download" href="/fixtures.blob?sql=select+*+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"><Binary:\xa07\xa0bytes></a></td>'
|
||||
'<td class="col-data"><a class="blob-download" href="/fixtures/-/query.blob?sql=select+*+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"><Binary:\xa07\xa0bytes></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&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&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/-/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&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 '<h1>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
|
||||
(
|
||||
|
|
|
@ -25,15 +25,15 @@ async def test_load_extension_default_entrypoint():
|
|||
# should fail.
|
||||
ds = Datasette(sqlite_extensions=[COMPILED_EXTENSION_PATH])
|
||||
|
||||
response = await ds.client.get("/_memory.json?_shape=arrays&sql=select+a()")
|
||||
response = await ds.client.get("/_memory/-/query.json?_shape=arrays&sql=select+a()")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rows"][0][0] == "a"
|
||||
|
||||
response = await ds.client.get("/_memory.json?_shape=arrays&sql=select+b()")
|
||||
response = await ds.client.get("/_memory/-/query.json?_shape=arrays&sql=select+b()")
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "no such function: b"
|
||||
|
||||
response = await ds.client.get("/_memory.json?_shape=arrays&sql=select+c()")
|
||||
response = await ds.client.get("/_memory/-/query.json?_shape=arrays&sql=select+c()")
|
||||
assert response.status_code == 400
|
||||
assert response.json()["error"] == "no such function: c"
|
||||
|
||||
|
@ -51,14 +51,14 @@ async def test_load_extension_multiple_entrypoints():
|
|||
]
|
||||
)
|
||||
|
||||
response = await ds.client.get("/_memory.json?_shape=arrays&sql=select+a()")
|
||||
response = await ds.client.get("/_memory/-/query.json?_shape=arrays&sql=select+a()")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rows"][0][0] == "a"
|
||||
|
||||
response = await ds.client.get("/_memory.json?_shape=arrays&sql=select+b()")
|
||||
response = await ds.client.get("/_memory/-/query.json?_shape=arrays&sql=select+b()")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rows"][0][0] == "b"
|
||||
|
||||
response = await ds.client.get("/_memory.json?_shape=arrays&sql=select+c()")
|
||||
response = await ds.client.get("/_memory/-/query.json?_shape=arrays&sql=select+c()")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rows"][0][0] == "c"
|
||||
|
|
|
@ -12,7 +12,7 @@ import pytest
|
|||
],
|
||||
)
|
||||
async def test_add_message_sets_cookie(ds_client, qs, expected):
|
||||
response = await ds_client.get(f"/fixtures.message?sql=select+1&{qs}")
|
||||
response = await ds_client.get(f"/fixtures/-/query.message?sql=select+1&{qs}")
|
||||
signed = response.cookies["ds_messages"]
|
||||
decoded = ds_client.ds.unsign(signed, "messages")
|
||||
assert expected == decoded
|
||||
|
@ -22,7 +22,7 @@ async def test_add_message_sets_cookie(ds_client, qs, expected):
|
|||
async def test_messages_are_displayed_and_cleared(ds_client):
|
||||
# First set the message cookie
|
||||
set_msg_response = await ds_client.get(
|
||||
"/fixtures.message?sql=select+1&add_msg=xmessagex"
|
||||
"/fixtures/-/query.message?sql=select+1&add_msg=xmessagex"
|
||||
)
|
||||
# Now access a page that displays messages
|
||||
response = await ds_client.get("/", cookies=set_msg_response.cookies)
|
||||
|
|
|
@ -268,7 +268,7 @@ def test_view_query(allow, expected_anon, expected_auth):
|
|||
def test_execute_sql(config):
|
||||
schema_re = re.compile("const schema = ({.*?});", re.DOTALL)
|
||||
with make_app_client(config=config) as client:
|
||||
form_fragment = '<form class="sql" action="/fixtures"'
|
||||
form_fragment = '<form class="sql" action="/fixtures/-/query"'
|
||||
|
||||
# Anonymous users - should not display the form:
|
||||
anon_html = client.get("/fixtures").text
|
||||
|
@ -276,7 +276,7 @@ def test_execute_sql(config):
|
|||
# And const schema should be an empty object:
|
||||
assert "const schema = {};" in anon_html
|
||||
# This should 403:
|
||||
assert client.get("/fixtures?sql=select+1").status == 403
|
||||
assert client.get("/fixtures/-/query?sql=select+1").status == 403
|
||||
# ?_where= not allowed on tables:
|
||||
assert client.get("/fixtures/facet_cities?_where=id=3").status == 403
|
||||
|
||||
|
@ -289,7 +289,7 @@ def test_execute_sql(config):
|
|||
assert set(schema["attraction_characteristic"]) == {"name", "pk"}
|
||||
assert schema["paginated_view"] == []
|
||||
assert form_fragment in response_text
|
||||
query_response = client.get("/fixtures?sql=select+1", cookies=cookies)
|
||||
query_response = client.get("/fixtures/-/query?sql=select+1", cookies=cookies)
|
||||
assert query_response.status == 200
|
||||
schema2 = json.loads(schema_re.search(query_response.text).group(1))
|
||||
assert set(schema2["attraction_characteristic"]) == {"name", "pk"}
|
||||
|
@ -337,7 +337,7 @@ def test_query_list_respects_view_query():
|
|||
],
|
||||
),
|
||||
(
|
||||
"/fixtures?sql=select+1",
|
||||
"/fixtures/-/query?sql=select+1",
|
||||
[
|
||||
"view-instance",
|
||||
("view-database", "fixtures"),
|
||||
|
|
|
@ -45,7 +45,7 @@ def test_plugin_hooks_have_tests(plugin_hook):
|
|||
@pytest.mark.asyncio
|
||||
async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.json?_shape=arrayfirst&sql=select+convert_units(100%2C+'m'%2C+'ft')"
|
||||
"/fixtures/-/query.json?_shape=arrayfirst&sql=select+convert_units(100%2C+'m'%2C+'ft')"
|
||||
)
|
||||
assert response.json()[0] == pytest.approx(328.0839)
|
||||
|
||||
|
@ -53,7 +53,7 @@ async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_hook_plugin_prepare_connection_arguments(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.json?sql=select+prepare_connection_args()&_shape=arrayfirst"
|
||||
"/fixtures/-/query.json?sql=select+prepare_connection_args()&_shape=arrayfirst"
|
||||
)
|
||||
assert [
|
||||
"database=fixtures, datasette.plugin_config(\"name-of-plugin\")={'depth': 'root'}"
|
||||
|
@ -176,7 +176,7 @@ async def test_hook_render_cell_link_from_json(ds_client):
|
|||
sql = """
|
||||
select '{"href": "http://example.com/", "label":"Example"}'
|
||||
""".strip()
|
||||
path = "/fixtures?" + urllib.parse.urlencode({"sql": sql})
|
||||
path = "/fixtures/-/query?" + urllib.parse.urlencode({"sql": sql})
|
||||
response = await ds_client.get(path)
|
||||
td = Soup(response.text, "html.parser").find("table").find("tbody").find("td")
|
||||
a = td.find("a")
|
||||
|
@ -205,7 +205,11 @@ async def test_hook_render_cell_demo(ds_client):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path", ("/fixtures?sql=select+'RENDER_CELL_ASYNC'", "/fixtures/simple_primary_key")
|
||||
"path",
|
||||
(
|
||||
"/fixtures/-/query?sql=select+'RENDER_CELL_ASYNC'",
|
||||
"/fixtures/simple_primary_key",
|
||||
),
|
||||
)
|
||||
async def test_hook_render_cell_async(ds_client, path):
|
||||
response = await ds_client.get(path)
|
||||
|
@ -423,7 +427,7 @@ def view_names_client(tmp_path_factory):
|
|||
("/fixtures/units", "table"),
|
||||
("/fixtures/units/1", "row"),
|
||||
("/-/versions", "json_data"),
|
||||
("/fixtures?sql=select+1", "database"),
|
||||
("/fixtures/-/query?sql=select+1", "database"),
|
||||
),
|
||||
)
|
||||
def test_view_names(view_names_client, path, view_name):
|
||||
|
@ -975,13 +979,13 @@ def get_actions_links(html):
|
|||
@pytest.mark.parametrize(
|
||||
"path,expected_url",
|
||||
(
|
||||
("/fixtures?sql=select+1", "/fixtures?sql=explain+select+1"),
|
||||
("/fixtures/-/query?sql=select+1", "/fixtures/-/query?sql=explain+select+1"),
|
||||
(
|
||||
"/fixtures/pragma_cache_size",
|
||||
"/fixtures?sql=explain+PRAGMA+cache_size%3B",
|
||||
"/fixtures/-/query?sql=explain+PRAGMA+cache_size%3B",
|
||||
),
|
||||
# Don't attempt to explain an explain
|
||||
("/fixtures?sql=explain+select+1", None),
|
||||
("/fixtures/-/query?sql=explain+select+1", None),
|
||||
),
|
||||
)
|
||||
async def test_hook_query_actions(ds_client, path, expected_url):
|
||||
|
@ -1475,7 +1479,7 @@ async def test_hook_top_row(ds_client):
|
|||
async def test_hook_top_query(ds_client):
|
||||
try:
|
||||
pm.register(SlotPlugin(), name="SlotPlugin")
|
||||
response = await ds_client.get("/fixtures?sql=select+1&z=x")
|
||||
response = await ds_client.get("/fixtures/-/query?sql=select+1&z=x")
|
||||
assert response.status_code == 200
|
||||
assert "Xtop_query:fixtures:select 1:x" in response.text
|
||||
finally:
|
||||
|
|
|
@ -95,7 +95,7 @@ async def test_db_with_route_databases(ds_with_route):
|
|||
("/original-name/t", 404),
|
||||
("/original-name/t/1", 404),
|
||||
("/custom-route-name", 200),
|
||||
("/custom-route-name?sql=select+id+from+t", 200),
|
||||
("/custom-route-name/-/query?sql=select+id+from+t", 200),
|
||||
("/custom-route-name/t", 200),
|
||||
("/custom-route-name/t/1", 200),
|
||||
),
|
||||
|
|
|
@ -57,7 +57,7 @@ async def test_table_shape_arrays(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_table_shape_arrayfirst(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.json?"
|
||||
"/fixtures/-/query.json?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
"sql": "select content from simple_primary_key order by id",
|
||||
|
@ -699,7 +699,7 @@ async def test_table_through(ds_client):
|
|||
@pytest.mark.asyncio
|
||||
async def test_max_returned_rows(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures.json?sql=select+content+from+no_primary_key"
|
||||
"/fixtures/-/query.json?sql=select+content+from+no_primary_key"
|
||||
)
|
||||
data = response.json()
|
||||
assert data["truncated"]
|
||||
|
|
|
@ -1199,7 +1199,9 @@ async def test_format_of_binary_links(size, title, length_bytes):
|
|||
expected = "{}><Binary: {} bytes></a>".format(title, length_bytes)
|
||||
assert expected in response.text
|
||||
# And test with arbitrary SQL query too
|
||||
sql_response = await ds.client.get("/{}".format(db_name), params={"sql": sql})
|
||||
sql_response = await ds.client.get(
|
||||
"{}/-/query".format(db_name), params={"sql": sql}
|
||||
)
|
||||
assert sql_response.status_code == 200
|
||||
assert expected in sql_response.text
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue