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

@ -37,7 +37,7 @@ from jinja2.exceptions import TemplateNotFound
from .events import Event from .events import Event
from .views import Context from .views import Context
from .views.base import ureg 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.index import IndexView
from .views.special import ( from .views.special import (
JsonDataView, JsonDataView,
@ -1578,6 +1578,10 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
) )
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$") 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( add_route(
wrap_view(table_view, self), wrap_view(table_view, self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",

View file

@ -21,7 +21,7 @@
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} {% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
{% if allow_execute_sql %} {% 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> <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><textarea id="sql-editor" name="sql">{% if tables %}select * from {{ tables[0].name|escape_sqlite }}{% else %}select sqlite_version(){% endif %}</textarea></p>
<p> <p>
@ -36,7 +36,7 @@
<p>The following databases are attached to this connection, and can be used for cross-database joins:</p> <p>The following databases are attached to this connection, and can be used for cross-database joins:</p>
<ul class="bullets"> <ul class="bullets">
{% for db_name in attached_databases %} {% 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 %} {% endfor %}
</ul> </ul>
</div> </div>

View file

@ -58,6 +58,11 @@ class DatabaseView(View):
sql = (request.args.get("sql") or "").strip() sql = (request.args.get("sql") or "").strip()
if sql: 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) return await QueryView()(request, datasette)
if format_ not in ("html", "json"): if format_ not in ("html", "json"):
@ -433,6 +438,8 @@ class QueryView(View):
async def get(self, request, datasette): async def get(self, request, datasette):
from datasette.app import TableNotFound from datasette.app import TableNotFound
await datasette.refresh_schemas()
db = await datasette.resolve_database(request) db = await datasette.resolve_database(request)
database = db.name database = db.name
@ -686,6 +693,7 @@ class QueryView(View):
if allow_execute_sql and is_validated_sql and ":_" not in sql: if allow_execute_sql and is_validated_sql and ":_" not in sql:
edit_sql_url = ( edit_sql_url = (
datasette.urls.database(database) datasette.urls.database(database)
+ "/-/query"
+ "?" + "?"
+ urlencode( + urlencode(
{ {

View file

@ -55,6 +55,21 @@ The following tables are hidden by default:
- Tables relating to the inner workings of the SpatiaLite SQLite extension. - Tables relating to the inner workings of the SpatiaLite SQLite extension.
- ``sqlite_stat`` tables used to store statistics used by the query optimizer. - ``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: .. _TableView:
Table Table

View file

@ -40,7 +40,7 @@ async () => {
import setuptools import setuptools
from datasette.app import Datasette from datasette.app import Datasette
ds = Datasette(memory=True, settings={'num_sql_threads': 0}) 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) { if (JSON.parse(output)[0].itworks != 55) {
throw 'Got ' + output + ', expected itworks: 55'; throw 'Got ' + output + ', expected itworks: 55';

View file

@ -411,6 +411,7 @@ def query_actions(datasette, database, query_name, sql):
return [ return [
{ {
"href": datasette.urls.database(database) "href": datasette.urls.database(database)
+ "/-/query"
+ "?" + "?"
+ urllib.parse.urlencode( + urllib.parse.urlencode(
{ {

View file

@ -623,7 +623,7 @@ def test_no_files_uses_memory_database(app_client_no_files):
} == response.json } == response.json
# Try that SQL query # Try that SQL query
response = app_client_no_files.get( 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 1 == len(response.json)
assert ["sqlite_version()"] == list(response.json[0].keys()) 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 @pytest.mark.asyncio
async def test_custom_sql(ds_client): async def test_custom_sql(ds_client):
response = await ds_client.get( 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() data = response.json()
assert data == { assert data == {
@ -670,7 +670,9 @@ async def test_custom_sql(ds_client):
def test_sql_time_limit(app_client_shorter_time_limit): 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 400 == response.status
assert response.json == { assert response.json == {
"ok": False, "ok": False,
@ -691,16 +693,22 @@ def test_sql_time_limit(app_client_shorter_time_limit):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_custom_sql_time_limit(ds_client): 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 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.status_code == 400
assert response.json()["title"] == "SQL Interrupted" assert response.json()["title"] == "SQL Interrupted"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_custom_sql(ds_client): 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.status_code == 400
assert response.json()["ok"] is False assert response.json()["ok"] is False
assert "Statement must be a SELECT" == response.json()["error"] 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, select 1 as intval, "s" as strval, 0.5 as floatval,
'{"foo": "bar"}' as jsonval '{"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 path += extra_args
response = await ds_client.get(path) response = await ds_client.get(
path,
)
assert response.json() == expected assert response.json() == expected
@ -917,7 +929,7 @@ def test_config_force_https_urls():
("/fixtures.json", 200), ("/fixtures.json", 200),
("/fixtures/no_primary_key.json", 200), ("/fixtures/no_primary_key.json", 200),
# A 400 invalid SQL query should still have the header: # 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 # Write APIs
("/fixtures/-/create", 405), ("/fixtures/-/create", 405),
("/fixtures/facetable/-/insert", 405), ("/fixtures/facetable/-/insert", 405),
@ -930,7 +942,9 @@ def test_cors(
path, path,
status_code, status_code,
): ):
response = app_client_with_cors.get(path) response = app_client_with_cors.get(
path,
)
assert response.status == status_code assert response.status == status_code
assert response.headers["Access-Control-Allow-Origin"] == "*" assert response.headers["Access-Control-Allow-Origin"] == "*"
assert ( assert (
@ -946,7 +960,9 @@ def test_cors(
# should not have those headers - I'm using that fixture because # should not have those headers - I'm using that fixture because
# regular app_client doesn't have immutable fixtures.db which means # regular app_client doesn't have immutable fixtures.db which means
# the test for /fixtures.db returns a 403 error # 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 response.status == status_code
assert "Access-Control-Allow-Origin" not in response.headers assert "Access-Control-Allow-Origin" not in response.headers
assert "Access-Control-Allow-Headers" not in response.headers assert "Access-Control-Allow-Headers" not in response.headers

View file

@ -637,7 +637,9 @@ async def test_delete_row(ds_write, table, row_for_create, pks, delete_path):
# Should be a single row # Should be a single row
assert ( assert (
await ds_write.client.get( 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] ).json() == [1]
# Now delete the row # 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 # Special case for that rowid table
delete_path = ( delete_path = (
await ds_write.client.get( 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] ).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 event.pks == str(delete_path).split(",")
assert ( assert (
await ds_write.client.get( 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] ).json() == [0]

View file

@ -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): def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_client):
response = magic_parameters_client.get( 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 400 == response.status
assert response.json["error"].startswith("You did not supply a value for binding") assert response.json["error"].startswith("You did not supply a value for binding")

View file

@ -250,7 +250,7 @@ def test_plugin_s_overwrite():
"--plugins-dir", "--plugins-dir",
plugins_dir, plugins_dir,
"--get", "--get",
"/_memory.json?sql=select+prepare_connection_args()", "/_memory/-/query.json?sql=select+prepare_connection_args()",
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
@ -265,7 +265,7 @@ def test_plugin_s_overwrite():
"--plugins-dir", "--plugins-dir",
plugins_dir, plugins_dir,
"--get", "--get",
"/_memory.json?sql=select+prepare_connection_args()", "/_memory/-/query.json?sql=select+prepare_connection_args()",
"-s", "-s",
"plugins.name-of-plugin", "plugins.name-of-plugin",
"OVERRIDE", "OVERRIDE",
@ -295,7 +295,7 @@ def test_setting_default_allow_sql(default_allow_sql):
"default_allow_sql", "default_allow_sql",
"on" if default_allow_sql else "off", "on" if default_allow_sql else "off",
"--get", "--get",
"/_memory.json?sql=select+21&_shape=objects", "/_memory/-/query.json?sql=select+21&_shape=objects",
], ],
) )
if default_allow_sql: if default_allow_sql:
@ -309,7 +309,7 @@ def test_setting_default_allow_sql(default_allow_sql):
def test_sql_errors_logged_to_stderr(): def test_sql_errors_logged_to_stderr():
runner = CliRunner(mix_stderr=False) 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 result.exit_code == 1
assert "sql = 'select blah', params = {}: no such column: blah\n" in result.stderr assert "sql = 'select blah', params = {}: no such column: blah\n" in result.stderr

View file

@ -31,7 +31,7 @@ def test_serve_with_get(tmp_path_factory):
"--plugins-dir", "--plugins-dir",
str(plugins_dir), str(plugins_dir),
"--get", "--get",
"/_memory.json?sql=select+sqlite_version()", "/_memory/-/query.json?sql=select+sqlite_version()",
], ],
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output

View file

@ -25,7 +25,8 @@ def test_crossdb_join(app_client_two_attached_databases_crossdb_enabled):
fixtures.searchable fixtures.searchable
""" """
response = app_client.get( 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.status == 200
assert response.json == [ assert response.json == [
@ -67,9 +68,10 @@ def test_crossdb_attached_database_list_display(
): ):
app_client = app_client_two_attached_databases_crossdb_enabled app_client = app_client_two_attached_databases_crossdb_enabled
response = app_client.get("/_memory") response = app_client.get("/_memory")
response2 = app_client.get("/")
for fragment in ( for fragment in (
"databases are attached to this connection", "databases are attached to this connection",
"<li><strong>fixtures</strong> - ", "<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 assert fragment in response.text

View file

@ -146,14 +146,14 @@ async def test_table_csv_blob_columns(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_custom_sql_csv_blob_columns(ds_client): async def test_custom_sql_csv_blob_columns(ds_client):
response = await ds_client.get( 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.status_code == 200
assert response.headers["content-type"] == "text/plain; charset=utf-8" assert response.headers["content-type"] == "text/plain; charset=utf-8"
assert response.text == ( assert response.text == (
"rowid,data\r\n" "rowid,data\r\n"
'1,"http://localhost/fixtures.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=f3088978da8f9aea479ffc7f631370b968d2e855eeb172bea7f6c7a04262bb6d"\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.blob?sql=select+rowid,+data+from+binary_data&_blob_column=data&_blob_hash=b835b0483cedb86130b9a2c280880bf5fadc5318ddf8c18d0df5204d40df1724"\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" "3,\r\n"
) )
@ -161,7 +161,7 @@ async def test_custom_sql_csv_blob_columns(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_custom_sql_csv(ds_client): async def test_custom_sql_csv(ds_client):
response = await ds_client.get( 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.status_code == 200
assert response.headers["content-type"] == "text/plain; charset=utf-8" 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 @pytest.mark.asyncio
async def test_csv_with_non_ascii_characters(ds_client): async def test_csv_with_non_ascii_characters(ds_client):
response = await ds_client.get( 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.status_code == 200
assert response.headers["content-type"] == "text/plain; charset=utf-8" assert response.headers["content-type"] == "text/plain; charset=utf-8"

View file

@ -159,7 +159,7 @@ async def test_database_page(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_custom_sql(ds_client): 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 response.status_code == 400
assert "Statement must be a SELECT" in response.text assert "Statement must be a SELECT" in response.text
@ -167,7 +167,7 @@ async def test_invalid_custom_sql(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_disallowed_custom_sql_pragma(ds_client): async def test_disallowed_custom_sql_pragma(ds_client):
response = await ds_client.get( 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 assert response.status_code == 400
pragmas = ", ".join("pragma_{}()".format(pragma) for pragma in allowed_pragmas) 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): 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 assert 400 == response.status
expected_html_fragments = [ expected_html_fragments = [
""" """
@ -207,7 +209,7 @@ def test_row_page_does_not_truncate():
def test_query_page_truncates(): def test_query_page_truncates():
with make_app_client(settings={"truncate_cells_html": 5}) as client: with make_app_client(settings={"truncate_cells_html": 5}) as client:
response = client.get( response = client.get(
"/fixtures?" "/fixtures/-/query?"
+ urllib.parse.urlencode( + urllib.parse.urlencode(
{ {
"sql": "select 'this is longer than 5' as a, 'https://example.com/' as b" "sql": "select 'this is longer than 5' as a, 'https://example.com/' as b"
@ -229,7 +231,7 @@ def test_query_page_truncates():
[ [
("/", ["index"]), ("/", ["index"]),
("/fixtures", ["db", "db-fixtures"]), ("/fixtures", ["db", "db-fixtures"]),
("/fixtures?sql=select+1", ["query", "db-fixtures"]), ("/fixtures/-/query?sql=select+1", ["query", "db-fixtures"]),
( (
"/fixtures/simple_primary_key", "/fixtures/simple_primary_key",
["table", "db-fixtures", "table-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 @pytest.mark.asyncio
async def test_query_json_csv_export_links(ds_client): 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 response.status_code == 200
assert '<a href="/fixtures.json?sql=select+1">json</a>' in response.text assert '<a href="/fixtures/-/query.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.csv?sql=select+1&amp;_size=max">CSV</a>'
in response.text
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_parameter_form_fields(ds_client): 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 response.status_code == 200
assert ( assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="">' '<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="">'
in response.text 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 response2.status_code == 200
assert ( assert (
'<label for="qp1">name</label> <input type="text" id="qp1" name="name" value="hello">' '<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 @pytest.mark.asyncio
async def test_database_metadata_with_custom_sql(ds_client): 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 assert response.status_code == 200
soup = Soup(response.text, "html.parser") soup = Soup(response.text, "html.parser")
# Page title should be the default # Page title should be the default
@ -591,7 +598,7 @@ async def test_canned_query_with_custom_metadata(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_urlify_custom_queries(ds_client): 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;"} {"sql": "select ('https://twitter.com/' || 'simonw') as user_url;"}
) )
response = await ds_client.get(path) response = await ds_client.get(path)
@ -609,7 +616,7 @@ async def test_urlify_custom_queries(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_show_hide_sql_query(ds_client): 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;"} {"sql": "select ('https://twitter.com/' || 'simonw') as user_url;"}
) )
response = await ds_client.get(path) response = await ds_client.get(path)
@ -696,15 +703,15 @@ def test_canned_query_show_hide_metadata_option(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_binary_data_display_in_query(ds_client): 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 assert response.status_code == 200
table = Soup(response.content, "html.parser").find("table") table = Soup(response.content, "html.parser").find("table")
expected_tds = [ 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>'], ['<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/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", "data-f30889.blob",
), ),
], ],
@ -758,7 +765,7 @@ async def test_blob_download_invalid_messages(ds_client, path, expected_message)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path", "path",
[ [
"/fixtures?sql=select+*+from+[123_starts_with_digits]", "/fixtures/-/query?sql=select+*+from+[123_starts_with_digits]",
"/fixtures/123_starts_with_digits", "/fixtures/123_starts_with_digits",
], ],
) )
@ -771,7 +778,7 @@ async def test_zero_results(ds_client, path):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_query_error(ds_client): 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 html = response.text
assert '<p class="message-error">no such table: notatable</p>' in html assert '<p class="message-error">no such table: notatable</p>' in html
assert '<textarea id="sql-editor" name="sql" style="height: 3em' 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/paginated_view",
"/fixtures/facetable", "/fixtures/facetable",
"/fixtures/facetable?_facet=state", "/fixtures/facetable?_facet=state",
"/fixtures?sql=select+1", "/fixtures/-/query?sql=select+1",
], ],
) )
@pytest.mark.parametrize("use_prefix", (True, False)) @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/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/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/pragma_cache_size", None),
( (
# /fixtures/𝐜𝐢𝐭𝐢𝐞𝐬 # /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/~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), ("/fixtures/magic_parameters", None),
], ],
@ -960,7 +967,7 @@ async def test_navigation_menu_links(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_trace_correctly_escaped(ds_client): 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" not in response.text
assert "select &#39;&lt;h1&gt;Hello" 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 # Custom query page
( (
"/fixtures?sql=select+*+from+facetable", "/fixtures/-/query?sql=select+*+from+facetable",
"http://localhost/fixtures.json?sql=select+*+from+facetable", "http://localhost/fixtures/-/query.json?sql=select+*+from+facetable",
), ),
# Canned query page # Canned query page
( (

View file

@ -25,15 +25,15 @@ async def test_load_extension_default_entrypoint():
# should fail. # should fail.
ds = Datasette(sqlite_extensions=[COMPILED_EXTENSION_PATH]) 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.status_code == 200
assert response.json()["rows"][0][0] == "a" 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.status_code == 400
assert response.json()["error"] == "no such function: b" 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.status_code == 400
assert response.json()["error"] == "no such function: c" 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.status_code == 200
assert response.json()["rows"][0][0] == "a" 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.status_code == 200
assert response.json()["rows"][0][0] == "b" 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.status_code == 200
assert response.json()["rows"][0][0] == "c" assert response.json()["rows"][0][0] == "c"

View file

@ -12,7 +12,7 @@ import pytest
], ],
) )
async def test_add_message_sets_cookie(ds_client, qs, expected): 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"] signed = response.cookies["ds_messages"]
decoded = ds_client.ds.unsign(signed, "messages") decoded = ds_client.ds.unsign(signed, "messages")
assert expected == decoded 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): async def test_messages_are_displayed_and_cleared(ds_client):
# First set the message cookie # First set the message cookie
set_msg_response = await ds_client.get( 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 # Now access a page that displays messages
response = await ds_client.get("/", cookies=set_msg_response.cookies) response = await ds_client.get("/", cookies=set_msg_response.cookies)

View file

@ -268,7 +268,7 @@ def test_view_query(allow, expected_anon, expected_auth):
def test_execute_sql(config): def test_execute_sql(config):
schema_re = re.compile("const schema = ({.*?});", re.DOTALL) schema_re = re.compile("const schema = ({.*?});", re.DOTALL)
with make_app_client(config=config) as client: 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: # Anonymous users - should not display the form:
anon_html = client.get("/fixtures").text anon_html = client.get("/fixtures").text
@ -276,7 +276,7 @@ def test_execute_sql(config):
# And const schema should be an empty object: # And const schema should be an empty object:
assert "const schema = {};" in anon_html assert "const schema = {};" in anon_html
# This should 403: # 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: # ?_where= not allowed on tables:
assert client.get("/fixtures/facet_cities?_where=id=3").status == 403 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 set(schema["attraction_characteristic"]) == {"name", "pk"}
assert schema["paginated_view"] == [] assert schema["paginated_view"] == []
assert form_fragment in response_text 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 assert query_response.status == 200
schema2 = json.loads(schema_re.search(query_response.text).group(1)) schema2 = json.loads(schema_re.search(query_response.text).group(1))
assert set(schema2["attraction_characteristic"]) == {"name", "pk"} 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-instance",
("view-database", "fixtures"), ("view-database", "fixtures"),

View file

@ -45,7 +45,7 @@ def test_plugin_hooks_have_tests(plugin_hook):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hook_plugins_dir_plugin_prepare_connection(ds_client): async def test_hook_plugins_dir_plugin_prepare_connection(ds_client):
response = await ds_client.get( 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) 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 @pytest.mark.asyncio
async def test_hook_plugin_prepare_connection_arguments(ds_client): async def test_hook_plugin_prepare_connection_arguments(ds_client):
response = await ds_client.get( 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 [ assert [
"database=fixtures, datasette.plugin_config(\"name-of-plugin\")={'depth': 'root'}" "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 = """ sql = """
select '{"href": "http://example.com/", "label":"Example"}' select '{"href": "http://example.com/", "label":"Example"}'
""".strip() """.strip()
path = "/fixtures?" + urllib.parse.urlencode({"sql": sql}) path = "/fixtures/-/query?" + urllib.parse.urlencode({"sql": sql})
response = await ds_client.get(path) response = await ds_client.get(path)
td = Soup(response.text, "html.parser").find("table").find("tbody").find("td") td = Soup(response.text, "html.parser").find("table").find("tbody").find("td")
a = td.find("a") a = td.find("a")
@ -205,7 +205,11 @@ async def test_hook_render_cell_demo(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @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): async def test_hook_render_cell_async(ds_client, path):
response = await ds_client.get(path) response = await ds_client.get(path)
@ -423,7 +427,7 @@ def view_names_client(tmp_path_factory):
("/fixtures/units", "table"), ("/fixtures/units", "table"),
("/fixtures/units/1", "row"), ("/fixtures/units/1", "row"),
("/-/versions", "json_data"), ("/-/versions", "json_data"),
("/fixtures?sql=select+1", "database"), ("/fixtures/-/query?sql=select+1", "database"),
), ),
) )
def test_view_names(view_names_client, path, view_name): def test_view_names(view_names_client, path, view_name):
@ -975,13 +979,13 @@ def get_actions_links(html):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path,expected_url", "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/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 # 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): 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): async def test_hook_top_query(ds_client):
try: try:
pm.register(SlotPlugin(), name="SlotPlugin") 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 response.status_code == 200
assert "Xtop_query:fixtures:select 1:x" in response.text assert "Xtop_query:fixtures:select 1:x" in response.text
finally: finally:

View file

@ -95,7 +95,7 @@ async def test_db_with_route_databases(ds_with_route):
("/original-name/t", 404), ("/original-name/t", 404),
("/original-name/t/1", 404), ("/original-name/t/1", 404),
("/custom-route-name", 200), ("/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", 200),
("/custom-route-name/t/1", 200), ("/custom-route-name/t/1", 200),
), ),

View file

@ -57,7 +57,7 @@ async def test_table_shape_arrays(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_table_shape_arrayfirst(ds_client): async def test_table_shape_arrayfirst(ds_client):
response = await ds_client.get( response = await ds_client.get(
"/fixtures.json?" "/fixtures/-/query.json?"
+ urllib.parse.urlencode( + urllib.parse.urlencode(
{ {
"sql": "select content from simple_primary_key order by id", "sql": "select content from simple_primary_key order by id",
@ -699,7 +699,7 @@ async def test_table_through(ds_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_max_returned_rows(ds_client): async def test_max_returned_rows(ds_client):
response = await ds_client.get( 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() data = response.json()
assert data["truncated"] assert data["truncated"]

View file

@ -1199,7 +1199,9 @@ async def test_format_of_binary_links(size, title, length_bytes):
expected = "{}>&lt;Binary:&nbsp;{}&nbsp;bytes&gt;</a>".format(title, length_bytes) expected = "{}>&lt;Binary:&nbsp;{}&nbsp;bytes&gt;</a>".format(title, length_bytes)
assert expected in response.text assert expected in response.text
# And test with arbitrary SQL query too # 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 sql_response.status_code == 200
assert expected in sql_response.text assert expected in sql_response.text