Basic writable canned queries

Refs #698. First working version of this feature.

* request.post_vars() no longer discards empty values
pull/798/head
Simon Willison 2020-06-03 08:16:50 -07:00 zatwierdzone przez GitHub
rodzic 0934844c0b
commit aa82d03704
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
6 zmienionych plików z 256 dodań i 19 usunięć

Wyświetl plik

@ -27,11 +27,12 @@
{% endblock %}
{% block content %}
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1>
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="get">
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
<h3>Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
{% if not hide_sql %}
{% if editable and config.allow_sql %}
@ -74,7 +75,9 @@
</tbody>
</table>
{% else %}
<p class="zero-results">0 results</p>
{% if not canned_write %}
<p class="zero-results">0 results</p>
{% endif %}
{% endif %}
{% include "_codemirror_foot.html" %}

Wyświetl plik

@ -106,6 +106,8 @@ class QueryView(DataView):
canned_query=None,
metadata=None,
_size=None,
named_parameters=None,
write=False,
):
params = {key: request.args.get(key) for key in request.args}
if "sql" in params:
@ -113,7 +115,7 @@ class QueryView(DataView):
if "_shape" in params:
params.pop("_shape")
# Extract any :named parameters
named_parameters = self.re_named_parameter.findall(sql)
named_parameters = named_parameters or self.re_named_parameter.findall(sql)
named_parameter_values = {
named_parameter: params.get(named_parameter) or ""
for named_parameter in named_parameters
@ -129,12 +131,60 @@ class QueryView(DataView):
extra_args["custom_time_limit"] = int(params["_timelimit"])
if _size:
extra_args["page_size"] = _size
results = await self.ds.execute(
database, sql, params, truncate=True, **extra_args
)
columns = [r[0] for r in results.description]
templates = ["query-{}.html".format(to_css_class(database)), "query.html"]
# Execute query - as write or as read
if write:
if request.method == "POST":
params = await request.post_vars()
try:
cursor = await self.ds.databases[database].execute_write(
sql, params, block=True
)
message = metadata.get(
"on_success_message"
) or "Query executed, {} row{} affected".format(
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
)
message_type = self.ds.INFO
redirect_url = metadata.get("on_success_redirect")
except Exception as e:
message = metadata.get("on_error_message") or str(e)
message_type = self.ds.ERROR
redirect_url = metadata.get("on_error_redirect")
self.ds.add_message(request, message, message_type)
return self.redirect(request, redirect_url or request.path)
else:
async def extra_template():
return {
"request": request,
"path_with_added_args": path_with_added_args,
"path_with_removed_args": path_with_removed_args,
"named_parameter_values": named_parameter_values,
"canned_query": canned_query,
"success_message": request.args.get("_success") or "",
"canned_write": True,
}
return (
{
"database": database,
"rows": [],
"truncated": False,
"columns": [],
"query": {"sql": sql, "params": params},
},
extra_template,
templates,
)
else: # Not a write
results = await self.ds.execute(
database, sql, params, truncate=True, **extra_args
)
columns = [r[0] for r in results.description]
if canned_query:
templates.insert(
0,

Wyświetl plik

@ -221,6 +221,22 @@ class RowTableShared(DataView):
class TableView(RowTableShared):
name = "table"
async def post(self, request, db_name, table_and_format):
# Handle POST to a canned query
canned_query = self.ds.get_canned_query(db_name, table_and_format)
assert canned_query, "You may only POST to a canned query"
return await QueryView(self.ds).data(
request,
db_name,
None,
canned_query["sql"],
metadata=canned_query,
editable=False,
canned_query=table_and_format,
named_parameters=canned_query.get("params"),
write=bool(canned_query.get("write")),
)
async def data(
self,
request,
@ -241,6 +257,8 @@ class TableView(RowTableShared):
metadata=canned_query,
editable=False,
canned_query=table,
named_parameters=canned_query.get("params"),
write=bool(canned_query.get("write")),
)
db = self.ds.databases[database]

Wyświetl plik

@ -161,11 +161,12 @@ You can set a default fragment hash that will be included in the link to the can
{
"databases": {
"fixtures": {
"queries": {
"neighborhood_search": {
"sql": "select neighborhood, facet_cities.name, state\nfrom facetable join facet_cities on facetable.city_id = facet_cities.id\nwhere neighborhood like '%' || :text || '%' order by neighborhood;",
"fragment": "fragment-goes-here"
"fixtures": {
"queries": {
"neighborhood_search": {
"sql": "select neighborhood, facet_cities.name, state\nfrom facetable join facet_cities on facetable.city_id = facet_cities.id\nwhere neighborhood like '%' || :text || '%' order by neighborhood;",
"fragment": "fragment-goes-here"
}
}
}
}
@ -173,6 +174,60 @@ You can set a default fragment hash that will be included in the link to the can
`See here <https://latest.datasette.io/fixtures#queries>`__ for a demo of this in action.
.. _canned_queries_writable:
Writable canned queries
~~~~~~~~~~~~~~~~~~~~~~~
Canned queries by default are read-only. You can use the ``"write": true`` key to indicate that a canned query can write to the database.
.. code-block:: json
{
"databases": {
"mydatabase": {
"queries": {
"add_name": {
"sql": "INSERT INTO names (name) VALUES (:name)",
"write": true
}
}
}
}
}
This configuration will create a page at ``/mydatabase/add_name`` displaying a form with a ``name`` field. Submitting that form will execute the configured ``INSERT`` query.
You can customize how Datasette represents success and errors using the following optional properties:
- ``on_success_message`` - the message shown when a query is successful
- ``on_success_redirect`` - the path or URL the user is redirected to on success
- ``on_error_message`` - the message shown when a query throws an error
- ``on_error_redirect`` - the path or URL the user is redirected to on error
For example:
.. code-block:: json
{
"databases": {
"mydatabase": {
"queries": {
"add_name": {
"sql": "INSERT INTO names (name) VALUES (:name)",
"write": true,
"on_success_message": "Name inserted",
"on_success_redirect": "/mydatabase/names",
"on_error_message": "Name insert failed",
"on_error_redirect": "/mydatabase"
}
}
}
}
}
You may wish to use this feature in conjunction with :ref:`authentication`.
.. _pagination:
Pagination

Wyświetl plik

@ -14,7 +14,7 @@ import string
import tempfile
import textwrap
import time
from urllib.parse import unquote, quote
from urllib.parse import unquote, quote, urlencode
# This temp file is used by one of the plugin config tests
@ -54,10 +54,26 @@ class TestClient:
async def get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
):
return await self._get(path, allow_redirects, redirect_count, method, cookies)
return await self._request(
path, allow_redirects, redirect_count, method, cookies
)
async def _get(
self, path, allow_redirects=True, redirect_count=0, method="GET", cookies=None
@async_to_sync
async def post(
self, path, post_data=None, allow_redirects=True, redirect_count=0, cookies=None
):
return await self._request(
path, allow_redirects, redirect_count, "POST", cookies, post_data
)
async def _request(
self,
path,
allow_redirects=True,
redirect_count=0,
method="GET",
cookies=None,
post_data=None,
):
query_string = b""
if "?" in path:
@ -83,7 +99,13 @@ class TestClient:
"headers": headers,
}
instance = ApplicationCommunicator(self.asgi_app, scope)
await instance.send_input({"type": "http.request"})
if post_data:
body = urlencode(post_data, doseq=True).encode("utf-8")
await instance.send_input({"type": "http.request", "body": body})
else:
await instance.send_input({"type": "http.request"})
# First message back should be response.start with headers and status
messages = []
start = await instance.receive_output(2)
@ -110,7 +132,7 @@ class TestClient:
redirect_count, self.max_redirects
)
location = response.headers["Location"]
return await self._get(
return await self._request(
location, allow_redirects=True, redirect_count=redirect_count + 1
)
return response
@ -128,6 +150,7 @@ def make_app_client(
inspect_data=None,
static_mounts=None,
template_dir=None,
metadata=None,
):
with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, filename)
@ -161,7 +184,7 @@ def make_app_client(
immutables=immutables,
memory=memory,
cors=cors,
metadata=METADATA,
metadata=metadata or METADATA,
plugins_dir=PLUGINS_DIR,
config=config,
inspect_data=inspect_data,

Wyświetl plik

@ -0,0 +1,88 @@
import pytest
from .fixtures import make_app_client
@pytest.fixture
def canned_write_client():
for client in make_app_client(
extra_databases={"data.db": "create table names (name text)"},
metadata={
"databases": {
"data": {
"queries": {
"add_name": {
"sql": "insert into names (name) values (:name)",
"write": True,
"on_success_redirect": "/data/add_name?success",
},
"add_name_specify_id": {
"sql": "insert into names (rowid, name) values (:rowid, :name)",
"write": True,
"on_error_redirect": "/data/add_name_specify_id?error",
},
"delete_name": {
"sql": "delete from names where rowid = :rowid",
"write": True,
"on_success_message": "Name deleted",
},
"update_name": {
"sql": "update names set name = :name where rowid = :rowid",
"params": ["rowid", "name"],
"write": True,
},
}
}
}
},
):
yield client
def test_insert(canned_write_client):
response = canned_write_client.post(
"/data/add_name", {"name": "Hello"}, allow_redirects=False
)
assert 302 == response.status
assert "/data/add_name?success" == response.headers["Location"]
messages = canned_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert [["Query executed, 1 row affected", 1]] == messages
def test_custom_success_message(canned_write_client):
response = canned_write_client.post(
"/data/delete_name", {"rowid": 1}, allow_redirects=False
)
assert 302 == response.status
messages = canned_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert [["Name deleted", 1]] == messages
def test_insert_error(canned_write_client):
canned_write_client.post("/data/add_name", {"name": "Hello"})
response = canned_write_client.post(
"/data/add_name_specify_id",
{"rowid": 1, "name": "Should fail"},
allow_redirects=False,
)
assert 302 == response.status
assert "/data/add_name_specify_id?error" == response.headers["Location"]
messages = canned_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)
assert [["UNIQUE constraint failed: names.rowid", 3]] == messages
# How about with a custom error message?
canned_write_client.ds._metadata["databases"]["data"]["queries"][
"add_name_specify_id"
]["on_error_message"] = "ERROR"
response = canned_write_client.post(
"/data/add_name_specify_id",
{"rowid": 1, "name": "Should fail"},
allow_redirects=False,
)
assert [["ERROR", 3]] == canned_write_client.ds.unsign(
response.cookies["ds_messages"], "messages"
)