mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Refactored canned query code, replaced old QueryView, closes #2114
This commit is contained in:
parent
cd57b0f712
commit
26be9f0445
4 changed files with 343 additions and 571 deletions
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if canned_write and db_is_immutable %}
|
{% if canned_query_write and db_is_immutable %}
|
||||||
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
<p class="message-error">This query cannot be executed because the database is immutable.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
<form class="sql" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
|
<form class="sql" action="{{ urls.database(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_query_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 %}{% if not query_error %}
|
<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 %}{% if not query_error %}
|
||||||
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
<span class="show-hide-sql">(<a href="{{ show_hide_link }}">{{ show_hide_text }}</a>)</span>
|
||||||
{% endif %}</h3>
|
{% endif %}</h3>
|
||||||
|
|
@ -61,8 +61,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p>
|
<p>
|
||||||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||||
{% if canned_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
|
{% if canned_query_write %}<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">{% endif %}
|
||||||
<input type="submit" value="Run SQL"{% if canned_write and db_is_immutable %} disabled{% endif %}>
|
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||||
{{ show_hide_hidden }}
|
{{ show_hide_hidden }}
|
||||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -87,7 +87,7 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if not canned_write and not error %}
|
{% if not canned_query_write and not error %}
|
||||||
<p class="zero-results">0 results</p>
|
<p class="zero-results">0 results</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from asyncinject import Registry
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from urllib.parse import parse_qsl, urlencode
|
from urllib.parse import parse_qsl, urlencode
|
||||||
|
|
@ -33,7 +32,7 @@ from datasette.utils import (
|
||||||
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
from datasette.utils.asgi import AsgiFileDownload, NotFound, Response, Forbidden
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
|
|
||||||
from .base import BaseView, DatasetteError, DataView, View, _error, stream_csv
|
from .base import BaseView, DatasetteError, View, _error, stream_csv
|
||||||
|
|
||||||
|
|
||||||
class DatabaseView(View):
|
class DatabaseView(View):
|
||||||
|
|
@ -57,7 +56,7 @@ class DatabaseView(View):
|
||||||
|
|
||||||
sql = (request.args.get("sql") or "").strip()
|
sql = (request.args.get("sql") or "").strip()
|
||||||
if sql:
|
if sql:
|
||||||
return await query_view(request, datasette)
|
return await QueryView()(request, datasette)
|
||||||
|
|
||||||
if format_ not in ("html", "json"):
|
if format_ not in ("html", "json"):
|
||||||
raise NotFound("Invalid format: {}".format(format_))
|
raise NotFound("Invalid format: {}".format(format_))
|
||||||
|
|
@ -65,10 +64,6 @@ class DatabaseView(View):
|
||||||
metadata = (datasette.metadata("databases") or {}).get(database, {})
|
metadata = (datasette.metadata("databases") or {}).get(database, {})
|
||||||
datasette.update_with_inherited_metadata(metadata)
|
datasette.update_with_inherited_metadata(metadata)
|
||||||
|
|
||||||
table_counts = await db.table_counts(5)
|
|
||||||
hidden_table_names = set(await db.hidden_table_names())
|
|
||||||
all_foreign_keys = await db.get_all_foreign_keys()
|
|
||||||
|
|
||||||
sql_views = []
|
sql_views = []
|
||||||
for view_name in await db.view_names():
|
for view_name in await db.view_names():
|
||||||
view_visible, view_private = await datasette.check_visibility(
|
view_visible, view_private = await datasette.check_visibility(
|
||||||
|
|
@ -196,8 +191,13 @@ class QueryContext:
|
||||||
# urls: dict = field(
|
# urls: dict = field(
|
||||||
# metadata={"help": "Object containing URL helpers like `database()`"}
|
# metadata={"help": "Object containing URL helpers like `database()`"}
|
||||||
# )
|
# )
|
||||||
canned_write: bool = field(
|
canned_query_write: bool = field(
|
||||||
metadata={"help": "Boolean indicating if this canned query allows writes"}
|
metadata={
|
||||||
|
"help": "Boolean indicating if this is a canned query that allows writes"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata: dict = field(
|
||||||
|
metadata={"help": "Metadata about the database or the canned query"}
|
||||||
)
|
)
|
||||||
db_is_immutable: bool = field(
|
db_is_immutable: bool = field(
|
||||||
metadata={"help": "Boolean indicating if this database is immutable"}
|
metadata={"help": "Boolean indicating if this database is immutable"}
|
||||||
|
|
@ -232,7 +232,6 @@ class QueryContext:
|
||||||
show_hide_hidden: str = field(
|
show_hide_hidden: str = field(
|
||||||
metadata={"help": "Hidden input field for the _show_sql parameter"}
|
metadata={"help": "Hidden input field for the _show_sql parameter"}
|
||||||
)
|
)
|
||||||
metadata: dict = field(metadata={"help": "Metadata about the query/database"})
|
|
||||||
database_color: Callable = field(
|
database_color: Callable = field(
|
||||||
metadata={"help": "Function that returns a color for a given database name"}
|
metadata={"help": "Function that returns a color for a given database name"}
|
||||||
)
|
)
|
||||||
|
|
@ -242,6 +241,12 @@ class QueryContext:
|
||||||
alternate_url_json: str = field(
|
alternate_url_json: str = field(
|
||||||
metadata={"help": "URL for alternate JSON version of this page"}
|
metadata={"help": "URL for alternate JSON version of this page"}
|
||||||
)
|
)
|
||||||
|
# TODO: refactor this to somewhere else, probably ds.render_template()
|
||||||
|
select_templates: list = field(
|
||||||
|
metadata={
|
||||||
|
"help": "List of templates that were considered for rendering this page"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_tables(datasette, request, db):
|
async def get_tables(datasette, request, db):
|
||||||
|
|
@ -320,287 +325,105 @@ async def database_download(request, datasette):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def query_view(
|
class QueryView(View):
|
||||||
request,
|
async def post(self, request, datasette):
|
||||||
datasette,
|
from datasette.app import TableNotFound
|
||||||
# canned_query=None,
|
|
||||||
# _size=None,
|
|
||||||
# named_parameters=None,
|
|
||||||
# write=False,
|
|
||||||
):
|
|
||||||
db = await datasette.resolve_database(request)
|
|
||||||
database = db.name
|
|
||||||
# Flattened because of ?sql=&name1=value1&name2=value2 feature
|
|
||||||
params = {key: request.args.get(key) for key in request.args}
|
|
||||||
sql = None
|
|
||||||
if "sql" in params:
|
|
||||||
sql = params.pop("sql")
|
|
||||||
if "_shape" in params:
|
|
||||||
params.pop("_shape")
|
|
||||||
|
|
||||||
# extras come from original request.args to avoid being flattened
|
db = await datasette.resolve_database(request)
|
||||||
extras = request.args.getlist("_extra")
|
|
||||||
|
|
||||||
# TODO: Behave differently for canned query here:
|
# We must be a canned query
|
||||||
await datasette.ensure_permissions(request.actor, [("execute-sql", database)])
|
table_found = False
|
||||||
|
try:
|
||||||
_, private = await datasette.check_visibility(
|
await datasette.resolve_table(request)
|
||||||
request.actor,
|
table_found = True
|
||||||
permissions=[
|
except TableNotFound as table_not_found:
|
||||||
("view-database", database),
|
canned_query = await datasette.get_canned_query(
|
||||||
"view-instance",
|
table_not_found.database_name, table_not_found.table, request.actor
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
extra_args = {}
|
|
||||||
if params.get("_timelimit"):
|
|
||||||
extra_args["custom_time_limit"] = int(params["_timelimit"])
|
|
||||||
|
|
||||||
format_ = request.url_vars.get("format") or "html"
|
|
||||||
query_error = None
|
|
||||||
try:
|
|
||||||
validate_sql_select(sql)
|
|
||||||
results = await datasette.execute(
|
|
||||||
database, sql, params, truncate=True, **extra_args
|
|
||||||
)
|
|
||||||
columns = results.columns
|
|
||||||
rows = results.rows
|
|
||||||
except QueryInterrupted as ex:
|
|
||||||
raise DatasetteError(
|
|
||||||
textwrap.dedent(
|
|
||||||
"""
|
|
||||||
<p>SQL query took too long. The time limit is controlled by the
|
|
||||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
|
||||||
configuration option.</p>
|
|
||||||
<textarea style="width: 90%">{}</textarea>
|
|
||||||
<script>
|
|
||||||
let ta = document.querySelector("textarea");
|
|
||||||
ta.style.height = ta.scrollHeight + "px";
|
|
||||||
</script>
|
|
||||||
""".format(
|
|
||||||
markupsafe.escape(ex.sql)
|
|
||||||
)
|
|
||||||
).strip(),
|
|
||||||
title="SQL Interrupted",
|
|
||||||
status=400,
|
|
||||||
message_is_html=True,
|
|
||||||
)
|
|
||||||
except sqlite3.DatabaseError as ex:
|
|
||||||
query_error = str(ex)
|
|
||||||
results = None
|
|
||||||
rows = []
|
|
||||||
columns = []
|
|
||||||
except (sqlite3.OperationalError, InvalidSql) as ex:
|
|
||||||
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
|
||||||
except sqlite3.OperationalError as ex:
|
|
||||||
raise DatasetteError(str(ex))
|
|
||||||
except DatasetteError:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Handle formats from plugins
|
|
||||||
if format_ == "csv":
|
|
||||||
|
|
||||||
async def fetch_data_for_csv(request, _next=None):
|
|
||||||
results = await db.execute(sql, params, truncate=True)
|
|
||||||
data = {"rows": results.rows, "columns": results.columns}
|
|
||||||
return data, None, None
|
|
||||||
|
|
||||||
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
|
||||||
elif format_ in datasette.renderers.keys():
|
|
||||||
# Dispatch request to the correct output format renderer
|
|
||||||
# (CSV is not handled here due to streaming)
|
|
||||||
result = call_with_supported_arguments(
|
|
||||||
datasette.renderers[format_][0],
|
|
||||||
datasette=datasette,
|
|
||||||
columns=columns,
|
|
||||||
rows=rows,
|
|
||||||
sql=sql,
|
|
||||||
query_name=None,
|
|
||||||
database=database,
|
|
||||||
table=None,
|
|
||||||
request=request,
|
|
||||||
view_name="table",
|
|
||||||
truncated=results.truncated if results else False,
|
|
||||||
error=query_error,
|
|
||||||
# These will be deprecated in Datasette 1.0:
|
|
||||||
args=request.args,
|
|
||||||
data={"rows": rows, "columns": columns},
|
|
||||||
)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
result = await result
|
|
||||||
if result is None:
|
|
||||||
raise NotFound("No data")
|
|
||||||
if isinstance(result, dict):
|
|
||||||
r = Response(
|
|
||||||
body=result.get("body"),
|
|
||||||
status=result.get("status_code") or 200,
|
|
||||||
content_type=result.get("content_type", "text/plain"),
|
|
||||||
headers=result.get("headers"),
|
|
||||||
)
|
)
|
||||||
elif isinstance(result, Response):
|
if canned_query is None:
|
||||||
r = result
|
raise
|
||||||
# if status_code is not None:
|
if table_found:
|
||||||
# # Over-ride the status code
|
# That should not have happened
|
||||||
# r.status = status_code
|
raise DatasetteError("Unexpected table found on POST", status=404)
|
||||||
else:
|
|
||||||
assert False, f"{result} should be dict or Response"
|
|
||||||
elif format_ == "html":
|
|
||||||
headers = {}
|
|
||||||
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
|
||||||
template = datasette.jinja_env.select_template(templates)
|
|
||||||
alternate_url_json = datasette.absolute_url(
|
|
||||||
request,
|
|
||||||
datasette.urls.path(path_with_format(request=request, format="json")),
|
|
||||||
)
|
|
||||||
data = {}
|
|
||||||
headers.update(
|
|
||||||
{
|
|
||||||
"Link": '{}; rel="alternate"; type="application/json+datasette"'.format(
|
|
||||||
alternate_url_json
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
metadata = (datasette.metadata("databases") or {}).get(database, {})
|
|
||||||
datasette.update_with_inherited_metadata(metadata)
|
|
||||||
|
|
||||||
renderers = {}
|
# If database is immutable, return an error
|
||||||
for key, (_, can_render) in datasette.renderers.items():
|
if not db.is_mutable:
|
||||||
it_can_render = call_with_supported_arguments(
|
raise Forbidden("Database is immutable")
|
||||||
can_render,
|
|
||||||
datasette=datasette,
|
# Process the POST
|
||||||
columns=data.get("columns") or [],
|
body = await request.post_body()
|
||||||
rows=data.get("rows") or [],
|
body = body.decode("utf-8").strip()
|
||||||
sql=data.get("query", {}).get("sql", None),
|
if body.startswith("{") and body.endswith("}"):
|
||||||
query_name=data.get("query_name"),
|
params = json.loads(body)
|
||||||
database=database,
|
# But we want key=value strings
|
||||||
table=data.get("table"),
|
for key, value in params.items():
|
||||||
request=request,
|
params[key] = str(value)
|
||||||
view_name="database",
|
else:
|
||||||
|
params = dict(parse_qsl(body, keep_blank_values=True))
|
||||||
|
# Should we return JSON?
|
||||||
|
should_return_json = (
|
||||||
|
request.headers.get("accept") == "application/json"
|
||||||
|
or request.args.get("_json")
|
||||||
|
or params.get("_json")
|
||||||
|
)
|
||||||
|
params_for_query = MagicParameters(params, request, datasette)
|
||||||
|
ok = None
|
||||||
|
redirect_url = None
|
||||||
|
try:
|
||||||
|
cursor = await db.execute_write(canned_query["sql"], params_for_query)
|
||||||
|
message = canned_query.get(
|
||||||
|
"on_success_message"
|
||||||
|
) or "Query executed, {} row{} affected".format(
|
||||||
|
cursor.rowcount, "" if cursor.rowcount == 1 else "s"
|
||||||
|
)
|
||||||
|
message_type = datasette.INFO
|
||||||
|
redirect_url = canned_query.get("on_success_redirect")
|
||||||
|
ok = True
|
||||||
|
except Exception as ex:
|
||||||
|
message = canned_query.get("on_error_message") or str(ex)
|
||||||
|
message_type = datasette.ERROR
|
||||||
|
redirect_url = canned_query.get("on_error_redirect")
|
||||||
|
ok = False
|
||||||
|
if should_return_json:
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
"ok": ok,
|
||||||
|
"message": message,
|
||||||
|
"redirect": redirect_url,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
it_can_render = await await_me_maybe(it_can_render)
|
|
||||||
if it_can_render:
|
|
||||||
renderers[key] = datasette.urls.path(
|
|
||||||
path_with_format(request=request, format=key)
|
|
||||||
)
|
|
||||||
|
|
||||||
allow_execute_sql = await datasette.permission_allowed(
|
|
||||||
request.actor, "execute-sql", database
|
|
||||||
)
|
|
||||||
|
|
||||||
show_hide_hidden = ""
|
|
||||||
if metadata.get("hide_sql"):
|
|
||||||
if bool(params.get("_show_sql")):
|
|
||||||
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
|
||||||
show_hide_text = "hide"
|
|
||||||
show_hide_hidden = '<input type="hidden" name="_show_sql" value="1">'
|
|
||||||
else:
|
|
||||||
show_hide_link = path_with_added_args(request, {"_show_sql": 1})
|
|
||||||
show_hide_text = "show"
|
|
||||||
else:
|
else:
|
||||||
if bool(params.get("_hide_sql")):
|
datasette.add_message(request, message, message_type)
|
||||||
show_hide_link = path_with_removed_args(request, {"_hide_sql"})
|
return Response.redirect(redirect_url or request.path)
|
||||||
show_hide_text = "show"
|
|
||||||
show_hide_hidden = '<input type="hidden" name="_hide_sql" value="1">'
|
|
||||||
else:
|
|
||||||
show_hide_link = path_with_added_args(request, {"_hide_sql": 1})
|
|
||||||
show_hide_text = "hide"
|
|
||||||
hide_sql = show_hide_text == "show"
|
|
||||||
|
|
||||||
# Extract any :named parameters
|
async def get(self, request, datasette):
|
||||||
named_parameters = await derive_named_parameters(
|
from datasette.app import TableNotFound
|
||||||
datasette.get_database(database), sql
|
|
||||||
)
|
|
||||||
named_parameter_values = {
|
|
||||||
named_parameter: params.get(named_parameter) or ""
|
|
||||||
for named_parameter in named_parameters
|
|
||||||
if not named_parameter.startswith("_")
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set to blank string if missing from params
|
db = await datasette.resolve_database(request)
|
||||||
for named_parameter in named_parameters:
|
|
||||||
if named_parameter not in params and not named_parameter.startswith("_"):
|
|
||||||
params[named_parameter] = ""
|
|
||||||
|
|
||||||
r = Response.html(
|
|
||||||
await datasette.render_template(
|
|
||||||
template,
|
|
||||||
QueryContext(
|
|
||||||
database=database,
|
|
||||||
query={
|
|
||||||
"sql": sql,
|
|
||||||
"params": params,
|
|
||||||
},
|
|
||||||
canned_query=None,
|
|
||||||
private=private,
|
|
||||||
canned_write=False,
|
|
||||||
db_is_immutable=not db.is_mutable,
|
|
||||||
error=query_error,
|
|
||||||
hide_sql=hide_sql,
|
|
||||||
show_hide_link=datasette.urls.path(show_hide_link),
|
|
||||||
show_hide_text=show_hide_text,
|
|
||||||
editable=True, # TODO
|
|
||||||
allow_execute_sql=allow_execute_sql,
|
|
||||||
tables=await get_tables(datasette, request, db),
|
|
||||||
named_parameter_values=named_parameter_values,
|
|
||||||
edit_sql_url="todo",
|
|
||||||
display_rows=await display_rows(
|
|
||||||
datasette, database, request, rows, columns
|
|
||||||
),
|
|
||||||
table_columns=await _table_columns(datasette, database)
|
|
||||||
if allow_execute_sql
|
|
||||||
else {},
|
|
||||||
columns=columns,
|
|
||||||
renderers=renderers,
|
|
||||||
url_csv=datasette.urls.path(
|
|
||||||
path_with_format(
|
|
||||||
request=request, format="csv", extra_qs={"_size": "max"}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
|
|
||||||
metadata=metadata,
|
|
||||||
database_color=lambda _: "#ff0000",
|
|
||||||
alternate_url_json=alternate_url_json,
|
|
||||||
),
|
|
||||||
request=request,
|
|
||||||
view_name="database",
|
|
||||||
),
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert False, "Invalid format: {}".format(format_)
|
|
||||||
if datasette.cors:
|
|
||||||
add_cors_headers(r.headers)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
class QueryView(DataView):
|
|
||||||
async def data(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
sql,
|
|
||||||
editable=True,
|
|
||||||
canned_query=None,
|
|
||||||
metadata=None,
|
|
||||||
_size=None,
|
|
||||||
named_parameters=None,
|
|
||||||
write=False,
|
|
||||||
default_labels=None,
|
|
||||||
):
|
|
||||||
db = await self.ds.resolve_database(request)
|
|
||||||
database = db.name
|
database = db.name
|
||||||
params = {key: request.args.get(key) for key in request.args}
|
|
||||||
if "sql" in params:
|
# Are we a canned query?
|
||||||
params.pop("sql")
|
canned_query = None
|
||||||
if "_shape" in params:
|
canned_query_write = False
|
||||||
params.pop("_shape")
|
if "table" in request.url_vars:
|
||||||
|
try:
|
||||||
|
await datasette.resolve_table(request)
|
||||||
|
except TableNotFound as table_not_found:
|
||||||
|
# Was this actually a canned query?
|
||||||
|
canned_query = await datasette.get_canned_query(
|
||||||
|
table_not_found.database_name, table_not_found.table, request.actor
|
||||||
|
)
|
||||||
|
if canned_query is None:
|
||||||
|
raise
|
||||||
|
canned_query_write = bool(canned_query.get("write"))
|
||||||
|
|
||||||
private = False
|
private = False
|
||||||
if canned_query:
|
if canned_query:
|
||||||
# Respect canned query permissions
|
# Respect canned query permissions
|
||||||
visible, private = await self.ds.check_visibility(
|
visible, private = await datasette.check_visibility(
|
||||||
request.actor,
|
request.actor,
|
||||||
permissions=[
|
permissions=[
|
||||||
("view-query", (database, canned_query)),
|
("view-query", (database, canned_query["name"])),
|
||||||
("view-database", database),
|
("view-database", database),
|
||||||
"view-instance",
|
"view-instance",
|
||||||
],
|
],
|
||||||
|
|
@ -609,18 +432,32 @@ class QueryView(DataView):
|
||||||
raise Forbidden("You do not have permission to view this query")
|
raise Forbidden("You do not have permission to view this query")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
await self.ds.ensure_permissions(request.actor, [("execute-sql", database)])
|
await datasette.ensure_permissions(
|
||||||
|
request.actor, [("execute-sql", database)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flattened because of ?sql=&name1=value1&name2=value2 feature
|
||||||
|
params = {key: request.args.get(key) for key in request.args}
|
||||||
|
sql = None
|
||||||
|
|
||||||
|
if canned_query:
|
||||||
|
sql = canned_query["sql"]
|
||||||
|
elif "sql" in params:
|
||||||
|
sql = params.pop("sql")
|
||||||
|
|
||||||
# Extract any :named parameters
|
# Extract any :named parameters
|
||||||
named_parameters = named_parameters or await derive_named_parameters(
|
named_parameters = []
|
||||||
self.ds.get_database(database), sql
|
if canned_query and canned_query.get("params"):
|
||||||
)
|
named_parameters = canned_query["params"]
|
||||||
|
if not named_parameters:
|
||||||
|
named_parameters = await derive_named_parameters(
|
||||||
|
datasette.get_database(database), sql
|
||||||
|
)
|
||||||
named_parameter_values = {
|
named_parameter_values = {
|
||||||
named_parameter: params.get(named_parameter) or ""
|
named_parameter: params.get(named_parameter) or ""
|
||||||
for named_parameter in named_parameters
|
for named_parameter in named_parameters
|
||||||
if not named_parameter.startswith("_")
|
if not named_parameter.startswith("_")
|
||||||
}
|
}
|
||||||
|
|
||||||
# Set to blank string if missing from params
|
# Set to blank string if missing from params
|
||||||
for named_parameter in named_parameters:
|
for named_parameter in named_parameters:
|
||||||
if named_parameter not in params and not named_parameter.startswith("_"):
|
if named_parameter not in params and not named_parameter.startswith("_"):
|
||||||
|
|
@ -629,212 +466,159 @@ class QueryView(DataView):
|
||||||
extra_args = {}
|
extra_args = {}
|
||||||
if params.get("_timelimit"):
|
if params.get("_timelimit"):
|
||||||
extra_args["custom_time_limit"] = int(params["_timelimit"])
|
extra_args["custom_time_limit"] = int(params["_timelimit"])
|
||||||
if _size:
|
|
||||||
extra_args["page_size"] = _size
|
|
||||||
|
|
||||||
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
format_ = request.url_vars.get("format") or "html"
|
||||||
if canned_query:
|
|
||||||
templates.insert(
|
|
||||||
0,
|
|
||||||
f"query-{to_css_class(database)}-{to_css_class(canned_query)}.html",
|
|
||||||
)
|
|
||||||
|
|
||||||
query_error = None
|
query_error = None
|
||||||
|
results = None
|
||||||
|
rows = []
|
||||||
|
columns = []
|
||||||
|
|
||||||
# Execute query - as write or as read
|
params_for_query = params
|
||||||
if write:
|
|
||||||
if request.method == "POST":
|
|
||||||
# If database is immutable, return an error
|
|
||||||
if not db.is_mutable:
|
|
||||||
raise Forbidden("Database is immutable")
|
|
||||||
body = await request.post_body()
|
|
||||||
body = body.decode("utf-8").strip()
|
|
||||||
if body.startswith("{") and body.endswith("}"):
|
|
||||||
params = json.loads(body)
|
|
||||||
# But we want key=value strings
|
|
||||||
for key, value in params.items():
|
|
||||||
params[key] = str(value)
|
|
||||||
else:
|
|
||||||
params = dict(parse_qsl(body, keep_blank_values=True))
|
|
||||||
# Should we return JSON?
|
|
||||||
should_return_json = (
|
|
||||||
request.headers.get("accept") == "application/json"
|
|
||||||
or request.args.get("_json")
|
|
||||||
or params.get("_json")
|
|
||||||
)
|
|
||||||
if canned_query:
|
|
||||||
params_for_query = MagicParameters(params, request, self.ds)
|
|
||||||
else:
|
|
||||||
params_for_query = params
|
|
||||||
ok = None
|
|
||||||
try:
|
|
||||||
cursor = await self.ds.databases[database].execute_write(
|
|
||||||
sql, params_for_query
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
ok = True
|
|
||||||
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")
|
|
||||||
ok = False
|
|
||||||
if should_return_json:
|
|
||||||
return Response.json(
|
|
||||||
{
|
|
||||||
"ok": ok,
|
|
||||||
"message": message,
|
|
||||||
"redirect": redirect_url,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.ds.add_message(request, message, message_type)
|
|
||||||
return self.redirect(request, redirect_url or request.path)
|
|
||||||
else:
|
|
||||||
|
|
||||||
async def extra_template():
|
if not canned_query_write:
|
||||||
return {
|
|
||||||
"request": request,
|
|
||||||
"db_is_immutable": not db.is_mutable,
|
|
||||||
"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},
|
|
||||||
"private": private,
|
|
||||||
},
|
|
||||||
extra_template,
|
|
||||||
templates,
|
|
||||||
)
|
|
||||||
else: # Not a write
|
|
||||||
if canned_query:
|
|
||||||
params_for_query = MagicParameters(params, request, self.ds)
|
|
||||||
else:
|
|
||||||
params_for_query = params
|
|
||||||
try:
|
try:
|
||||||
results = await self.ds.execute(
|
if not canned_query:
|
||||||
|
# For regular queries we only allow SELECT, plus other rules
|
||||||
|
validate_sql_select(sql)
|
||||||
|
else:
|
||||||
|
# Canned queries can run magic parameters
|
||||||
|
params_for_query = MagicParameters(params, request, datasette)
|
||||||
|
results = await datasette.execute(
|
||||||
database, sql, params_for_query, truncate=True, **extra_args
|
database, sql, params_for_query, truncate=True, **extra_args
|
||||||
)
|
)
|
||||||
columns = [r[0] for r in results.description]
|
columns = results.columns
|
||||||
except sqlite3.DatabaseError as e:
|
rows = results.rows
|
||||||
query_error = e
|
except QueryInterrupted as ex:
|
||||||
|
raise DatasetteError(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""
|
||||||
|
<p>SQL query took too long. The time limit is controlled by the
|
||||||
|
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||||
|
configuration option.</p>
|
||||||
|
<textarea style="width: 90%">{}</textarea>
|
||||||
|
<script>
|
||||||
|
let ta = document.querySelector("textarea");
|
||||||
|
ta.style.height = ta.scrollHeight + "px";
|
||||||
|
</script>
|
||||||
|
""".format(
|
||||||
|
markupsafe.escape(ex.sql)
|
||||||
|
)
|
||||||
|
).strip(),
|
||||||
|
title="SQL Interrupted",
|
||||||
|
status=400,
|
||||||
|
message_is_html=True,
|
||||||
|
)
|
||||||
|
except sqlite3.DatabaseError as ex:
|
||||||
|
query_error = str(ex)
|
||||||
results = None
|
results = None
|
||||||
|
rows = []
|
||||||
columns = []
|
columns = []
|
||||||
|
except (sqlite3.OperationalError, InvalidSql) as ex:
|
||||||
|
raise DatasetteError(str(ex), title="Invalid SQL", status=400)
|
||||||
|
except sqlite3.OperationalError as ex:
|
||||||
|
raise DatasetteError(str(ex))
|
||||||
|
except DatasetteError:
|
||||||
|
raise
|
||||||
|
|
||||||
allow_execute_sql = await self.ds.permission_allowed(
|
# Handle formats from plugins
|
||||||
request.actor, "execute-sql", database
|
if format_ == "csv":
|
||||||
)
|
|
||||||
|
|
||||||
async def extra_template():
|
async def fetch_data_for_csv(request, _next=None):
|
||||||
display_rows = []
|
results = await db.execute(sql, params, truncate=True)
|
||||||
truncate_cells = self.ds.setting("truncate_cells_html")
|
data = {"rows": results.rows, "columns": results.columns}
|
||||||
for row in results.rows if results else []:
|
return data, None, None
|
||||||
display_row = []
|
|
||||||
for column, value in zip(results.columns, row):
|
|
||||||
display_value = value
|
|
||||||
# Let the plugins have a go
|
|
||||||
# pylint: disable=no-member
|
|
||||||
plugin_display_value = None
|
|
||||||
for candidate in pm.hook.render_cell(
|
|
||||||
row=row,
|
|
||||||
value=value,
|
|
||||||
column=column,
|
|
||||||
table=None,
|
|
||||||
database=database,
|
|
||||||
datasette=self.ds,
|
|
||||||
request=request,
|
|
||||||
):
|
|
||||||
candidate = await await_me_maybe(candidate)
|
|
||||||
if candidate is not None:
|
|
||||||
plugin_display_value = candidate
|
|
||||||
break
|
|
||||||
if plugin_display_value is not None:
|
|
||||||
display_value = plugin_display_value
|
|
||||||
else:
|
|
||||||
if value in ("", None):
|
|
||||||
display_value = markupsafe.Markup(" ")
|
|
||||||
elif is_url(str(display_value).strip()):
|
|
||||||
display_value = markupsafe.Markup(
|
|
||||||
'<a href="{url}">{truncated_url}</a>'.format(
|
|
||||||
url=markupsafe.escape(value.strip()),
|
|
||||||
truncated_url=markupsafe.escape(
|
|
||||||
truncate_url(value.strip(), truncate_cells)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif isinstance(display_value, bytes):
|
|
||||||
blob_url = path_with_format(
|
|
||||||
request=request,
|
|
||||||
format="blob",
|
|
||||||
extra_qs={
|
|
||||||
"_blob_column": column,
|
|
||||||
"_blob_hash": hashlib.sha256(
|
|
||||||
display_value
|
|
||||||
).hexdigest(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
formatted = format_bytes(len(value))
|
|
||||||
display_value = markupsafe.Markup(
|
|
||||||
'<a class="blob-download" href="{}"{}><Binary: {:,} byte{}></a>'.format(
|
|
||||||
blob_url,
|
|
||||||
' title="{}"'.format(formatted)
|
|
||||||
if "bytes" not in formatted
|
|
||||||
else "",
|
|
||||||
len(value),
|
|
||||||
"" if len(value) == 1 else "s",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
display_value = str(value)
|
|
||||||
if truncate_cells and len(display_value) > truncate_cells:
|
|
||||||
display_value = (
|
|
||||||
display_value[:truncate_cells] + "\u2026"
|
|
||||||
)
|
|
||||||
display_row.append(display_value)
|
|
||||||
display_rows.append(display_row)
|
|
||||||
|
|
||||||
# Show 'Edit SQL' button only if:
|
return await stream_csv(datasette, fetch_data_for_csv, request, db.name)
|
||||||
# - User is allowed to execute SQL
|
elif format_ in datasette.renderers.keys():
|
||||||
# - SQL is an approved SELECT statement
|
# Dispatch request to the correct output format renderer
|
||||||
# - No magic parameters, so no :_ in the SQL string
|
# (CSV is not handled here due to streaming)
|
||||||
edit_sql_url = None
|
result = call_with_supported_arguments(
|
||||||
is_validated_sql = False
|
datasette.renderers[format_][0],
|
||||||
try:
|
datasette=datasette,
|
||||||
validate_sql_select(sql)
|
columns=columns,
|
||||||
is_validated_sql = True
|
rows=rows,
|
||||||
except InvalidSql:
|
sql=sql,
|
||||||
pass
|
query_name=canned_query["name"] if canned_query else None,
|
||||||
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
database=database,
|
||||||
edit_sql_url = (
|
table=None,
|
||||||
self.ds.urls.database(database)
|
request=request,
|
||||||
+ "?"
|
view_name="table",
|
||||||
+ urlencode(
|
truncated=results.truncated if results else False,
|
||||||
{
|
error=query_error,
|
||||||
**{
|
# These will be deprecated in Datasette 1.0:
|
||||||
"sql": sql,
|
args=request.args,
|
||||||
},
|
data={"rows": rows, "columns": columns},
|
||||||
**named_parameter_values,
|
)
|
||||||
}
|
if asyncio.iscoroutine(result):
|
||||||
)
|
result = await result
|
||||||
|
if result is None:
|
||||||
|
raise NotFound("No data")
|
||||||
|
if isinstance(result, dict):
|
||||||
|
r = Response(
|
||||||
|
body=result.get("body"),
|
||||||
|
status=result.get("status_code") or 200,
|
||||||
|
content_type=result.get("content_type", "text/plain"),
|
||||||
|
headers=result.get("headers"),
|
||||||
|
)
|
||||||
|
elif isinstance(result, Response):
|
||||||
|
r = result
|
||||||
|
# if status_code is not None:
|
||||||
|
# # Over-ride the status code
|
||||||
|
# r.status = status_code
|
||||||
|
else:
|
||||||
|
assert False, f"{result} should be dict or Response"
|
||||||
|
elif format_ == "html":
|
||||||
|
headers = {}
|
||||||
|
templates = [f"query-{to_css_class(database)}.html", "query.html"]
|
||||||
|
if canned_query:
|
||||||
|
templates.insert(
|
||||||
|
0,
|
||||||
|
f"query-{to_css_class(database)}-{to_css_class(canned_query['name'])}.html",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
template = datasette.jinja_env.select_template(templates)
|
||||||
|
alternate_url_json = datasette.absolute_url(
|
||||||
|
request,
|
||||||
|
datasette.urls.path(path_with_format(request=request, format="json")),
|
||||||
|
)
|
||||||
|
data = {}
|
||||||
|
headers.update(
|
||||||
|
{
|
||||||
|
"Link": '{}; rel="alternate"; type="application/json+datasette"'.format(
|
||||||
|
alternate_url_json
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
metadata = (datasette.metadata("databases") or {}).get(database, {})
|
||||||
|
datasette.update_with_inherited_metadata(metadata)
|
||||||
|
|
||||||
|
renderers = {}
|
||||||
|
for key, (_, can_render) in datasette.renderers.items():
|
||||||
|
it_can_render = call_with_supported_arguments(
|
||||||
|
can_render,
|
||||||
|
datasette=datasette,
|
||||||
|
columns=data.get("columns") or [],
|
||||||
|
rows=data.get("rows") or [],
|
||||||
|
sql=data.get("query", {}).get("sql", None),
|
||||||
|
query_name=data.get("query_name"),
|
||||||
|
database=database,
|
||||||
|
table=data.get("table"),
|
||||||
|
request=request,
|
||||||
|
view_name="database",
|
||||||
|
)
|
||||||
|
it_can_render = await await_me_maybe(it_can_render)
|
||||||
|
if it_can_render:
|
||||||
|
renderers[key] = datasette.urls.path(
|
||||||
|
path_with_format(request=request, format=key)
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_execute_sql = await datasette.permission_allowed(
|
||||||
|
request.actor, "execute-sql", database
|
||||||
|
)
|
||||||
|
|
||||||
show_hide_hidden = ""
|
show_hide_hidden = ""
|
||||||
if metadata.get("hide_sql"):
|
if canned_query and canned_query.get("hide_sql"):
|
||||||
if bool(params.get("_show_sql")):
|
if bool(params.get("_show_sql")):
|
||||||
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
show_hide_link = path_with_removed_args(request, {"_show_sql"})
|
||||||
show_hide_text = "hide"
|
show_hide_text = "hide"
|
||||||
|
|
@ -855,42 +639,86 @@ class QueryView(DataView):
|
||||||
show_hide_link = path_with_added_args(request, {"_hide_sql": 1})
|
show_hide_link = path_with_added_args(request, {"_hide_sql": 1})
|
||||||
show_hide_text = "hide"
|
show_hide_text = "hide"
|
||||||
hide_sql = show_hide_text == "show"
|
hide_sql = show_hide_text == "show"
|
||||||
return {
|
|
||||||
"display_rows": display_rows,
|
|
||||||
"custom_sql": True,
|
|
||||||
"named_parameter_values": named_parameter_values,
|
|
||||||
"editable": editable,
|
|
||||||
"canned_query": canned_query,
|
|
||||||
"edit_sql_url": edit_sql_url,
|
|
||||||
"metadata": metadata,
|
|
||||||
"settings": self.ds.settings_dict(),
|
|
||||||
"request": request,
|
|
||||||
"show_hide_link": self.ds.urls.path(show_hide_link),
|
|
||||||
"show_hide_text": show_hide_text,
|
|
||||||
"show_hide_hidden": markupsafe.Markup(show_hide_hidden),
|
|
||||||
"hide_sql": hide_sql,
|
|
||||||
"table_columns": await _table_columns(self.ds, database)
|
|
||||||
if allow_execute_sql
|
|
||||||
else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
# Show 'Edit SQL' button only if:
|
||||||
{
|
# - User is allowed to execute SQL
|
||||||
"ok": not query_error,
|
# - SQL is an approved SELECT statement
|
||||||
"database": database,
|
# - No magic parameters, so no :_ in the SQL string
|
||||||
"query_name": canned_query,
|
edit_sql_url = None
|
||||||
"rows": results.rows if results else [],
|
is_validated_sql = False
|
||||||
"truncated": results.truncated if results else False,
|
try:
|
||||||
"columns": columns,
|
validate_sql_select(sql)
|
||||||
"query": {"sql": sql, "params": params},
|
is_validated_sql = True
|
||||||
"error": str(query_error) if query_error else None,
|
except InvalidSql:
|
||||||
"private": private,
|
pass
|
||||||
"allow_execute_sql": allow_execute_sql,
|
if allow_execute_sql and is_validated_sql and ":_" not in sql:
|
||||||
},
|
edit_sql_url = (
|
||||||
extra_template,
|
datasette.urls.database(database)
|
||||||
templates,
|
+ "?"
|
||||||
400 if query_error else 200,
|
+ urlencode(
|
||||||
)
|
{
|
||||||
|
**{
|
||||||
|
"sql": sql,
|
||||||
|
},
|
||||||
|
**named_parameter_values,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
r = Response.html(
|
||||||
|
await datasette.render_template(
|
||||||
|
template,
|
||||||
|
QueryContext(
|
||||||
|
database=database,
|
||||||
|
query={
|
||||||
|
"sql": sql,
|
||||||
|
"params": params,
|
||||||
|
},
|
||||||
|
canned_query=canned_query["name"] if canned_query else None,
|
||||||
|
private=private,
|
||||||
|
canned_query_write=canned_query_write,
|
||||||
|
db_is_immutable=not db.is_mutable,
|
||||||
|
error=query_error,
|
||||||
|
hide_sql=hide_sql,
|
||||||
|
show_hide_link=datasette.urls.path(show_hide_link),
|
||||||
|
show_hide_text=show_hide_text,
|
||||||
|
editable=not canned_query,
|
||||||
|
allow_execute_sql=allow_execute_sql,
|
||||||
|
tables=await get_tables(datasette, request, db),
|
||||||
|
named_parameter_values=named_parameter_values,
|
||||||
|
edit_sql_url=edit_sql_url,
|
||||||
|
display_rows=await display_rows(
|
||||||
|
datasette, database, request, rows, columns
|
||||||
|
),
|
||||||
|
table_columns=await _table_columns(datasette, database)
|
||||||
|
if allow_execute_sql
|
||||||
|
else {},
|
||||||
|
columns=columns,
|
||||||
|
renderers=renderers,
|
||||||
|
url_csv=datasette.urls.path(
|
||||||
|
path_with_format(
|
||||||
|
request=request, format="csv", extra_qs={"_size": "max"}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
show_hide_hidden=markupsafe.Markup(show_hide_hidden),
|
||||||
|
metadata=canned_query or metadata,
|
||||||
|
database_color=lambda _: "#ff0000",
|
||||||
|
alternate_url_json=alternate_url_json,
|
||||||
|
select_templates=[
|
||||||
|
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||||
|
for template_name in templates
|
||||||
|
],
|
||||||
|
),
|
||||||
|
request=request,
|
||||||
|
view_name="database",
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert False, "Invalid format: {}".format(format_)
|
||||||
|
if datasette.cors:
|
||||||
|
add_cors_headers(r.headers)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
class MagicParameters(dict):
|
class MagicParameters(dict):
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import markupsafe
|
||||||
from datasette.plugins import pm
|
from datasette.plugins import pm
|
||||||
from datasette.database import QueryInterrupted
|
from datasette.database import QueryInterrupted
|
||||||
from datasette import tracer
|
from datasette import tracer
|
||||||
from datasette.renderer import json_renderer
|
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
add_cors_headers,
|
add_cors_headers,
|
||||||
await_me_maybe,
|
await_me_maybe,
|
||||||
|
|
@ -21,7 +20,6 @@ from datasette.utils import (
|
||||||
tilde_encode,
|
tilde_encode,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
filters_should_redirect,
|
filters_should_redirect,
|
||||||
format_bytes,
|
|
||||||
is_url,
|
is_url,
|
||||||
path_from_row_pks,
|
path_from_row_pks,
|
||||||
path_with_added_args,
|
path_with_added_args,
|
||||||
|
|
@ -38,7 +36,7 @@ from datasette.utils import (
|
||||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
|
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
|
||||||
from datasette.filters import Filters
|
from datasette.filters import Filters
|
||||||
import sqlite_utils
|
import sqlite_utils
|
||||||
from .base import BaseView, DataView, DatasetteError, ureg, _error, stream_csv
|
from .base import BaseView, DatasetteError, ureg, _error, stream_csv
|
||||||
from .database import QueryView
|
from .database import QueryView
|
||||||
|
|
||||||
LINK_WITH_LABEL = (
|
LINK_WITH_LABEL = (
|
||||||
|
|
@ -698,57 +696,6 @@ async def table_view(datasette, request):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class CannedQueryView(DataView):
|
|
||||||
def __init__(self, datasette):
|
|
||||||
self.ds = datasette
|
|
||||||
|
|
||||||
async def post(self, request):
|
|
||||||
from datasette.app import TableNotFound
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ds.resolve_table(request)
|
|
||||||
except TableNotFound as e:
|
|
||||||
# Was this actually a canned query?
|
|
||||||
canned_query = await self.ds.get_canned_query(
|
|
||||||
e.database_name, e.table, request.actor
|
|
||||||
)
|
|
||||||
if canned_query:
|
|
||||||
# Handle POST to a canned query
|
|
||||||
return await QueryView(self.ds).data(
|
|
||||||
request,
|
|
||||||
canned_query["sql"],
|
|
||||||
metadata=canned_query,
|
|
||||||
editable=False,
|
|
||||||
canned_query=e.table,
|
|
||||||
named_parameters=canned_query.get("params"),
|
|
||||||
write=bool(canned_query.get("write")),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response.text("Method not allowed", status=405)
|
|
||||||
|
|
||||||
async def data(self, request, **kwargs):
|
|
||||||
from datasette.app import TableNotFound
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.ds.resolve_table(request)
|
|
||||||
except TableNotFound as not_found:
|
|
||||||
canned_query = await self.ds.get_canned_query(
|
|
||||||
not_found.database_name, not_found.table, request.actor
|
|
||||||
)
|
|
||||||
if canned_query:
|
|
||||||
return await QueryView(self.ds).data(
|
|
||||||
request,
|
|
||||||
canned_query["sql"],
|
|
||||||
metadata=canned_query,
|
|
||||||
editable=False,
|
|
||||||
canned_query=not_found.table,
|
|
||||||
named_parameters=canned_query.get("params"),
|
|
||||||
write=bool(canned_query.get("write")),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def table_view_traced(datasette, request):
|
async def table_view_traced(datasette, request):
|
||||||
from datasette.app import TableNotFound
|
from datasette.app import TableNotFound
|
||||||
|
|
||||||
|
|
@ -761,10 +708,7 @@ async def table_view_traced(datasette, request):
|
||||||
)
|
)
|
||||||
# If this is a canned query, not a table, then dispatch to QueryView instead
|
# If this is a canned query, not a table, then dispatch to QueryView instead
|
||||||
if canned_query:
|
if canned_query:
|
||||||
if request.method == "POST":
|
return await QueryView()(request, datasette)
|
||||||
return await CannedQueryView(datasette).post(request)
|
|
||||||
else:
|
|
||||||
return await CannedQueryView(datasette).get(request)
|
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,12 +95,12 @@ def test_insert(canned_write_client):
|
||||||
csrftoken_from=True,
|
csrftoken_from=True,
|
||||||
cookies={"foo": "bar"},
|
cookies={"foo": "bar"},
|
||||||
)
|
)
|
||||||
assert 302 == response.status
|
|
||||||
assert "/data/add_name?success" == response.headers["Location"]
|
|
||||||
messages = canned_write_client.ds.unsign(
|
messages = canned_write_client.ds.unsign(
|
||||||
response.cookies["ds_messages"], "messages"
|
response.cookies["ds_messages"], "messages"
|
||||||
)
|
)
|
||||||
assert [["Query executed, 1 row affected", 1]] == messages
|
assert messages == [["Query executed, 1 row affected", 1]]
|
||||||
|
assert response.status == 302
|
||||||
|
assert response.headers["Location"] == "/data/add_name?success"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
@ -382,11 +382,11 @@ def test_magic_parameters_cannot_be_used_in_arbitrary_queries(magic_parameters_c
|
||||||
def test_canned_write_custom_template(canned_write_client):
|
def test_canned_write_custom_template(canned_write_client):
|
||||||
response = canned_write_client.get("/data/update_name")
|
response = canned_write_client.get("/data/update_name")
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
|
assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text
|
||||||
assert (
|
assert (
|
||||||
"<!-- Templates considered: *query-data-update_name.html, query-data.html, query.html -->"
|
"<!-- Templates considered: *query-data-update_name.html, query-data.html, query.html -->"
|
||||||
in response.text
|
in response.text
|
||||||
)
|
)
|
||||||
assert "!!!CUSTOM_UPDATE_NAME_TEMPLATE!!!" in response.text
|
|
||||||
# And test for link rel=alternate while we're here:
|
# And test for link rel=alternate while we're here:
|
||||||
assert (
|
assert (
|
||||||
'<link rel="alternate" type="application/json+datasette" href="http://localhost/data/update_name.json">'
|
'<link rel="alternate" type="application/json+datasette" href="http://localhost/data/update_name.json">'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue