mirror of
https://github.com/simonw/datasette.git
synced 2026-05-31 14:16:59 +02:00
parent
221be2632e
commit
4b5fac9cf7
7 changed files with 904 additions and 8 deletions
|
|
@ -43,7 +43,18 @@ from jinja2.exceptions import TemplateNotFound
|
|||
from .events import Event
|
||||
from .column_types import SQLiteType
|
||||
from .views import Context
|
||||
from .views.database import database_download, DatabaseView, TableCreateView, QueryView
|
||||
from .views.database import (
|
||||
database_download,
|
||||
DatabaseView,
|
||||
TableCreateView,
|
||||
QueryView,
|
||||
QueryCreateView,
|
||||
QueryDeleteView,
|
||||
QueryDefinitionView,
|
||||
QueryInsertView,
|
||||
QueryListView,
|
||||
QueryUpdateView,
|
||||
)
|
||||
from .views.index import IndexView
|
||||
from .views.special import (
|
||||
JsonDataView,
|
||||
|
|
@ -2524,6 +2535,18 @@ class Datasette:
|
|||
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
|
||||
add_route(
|
||||
QueryListView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries$",
|
||||
)
|
||||
add_route(
|
||||
QueryCreateView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries/-/create$",
|
||||
)
|
||||
add_route(
|
||||
QueryInsertView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries/-/insert$",
|
||||
)
|
||||
add_route(
|
||||
DatabaseSchemaView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
|
||||
|
|
@ -2532,6 +2555,18 @@ class Datasette:
|
|||
wrap_view(QueryView, self),
|
||||
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$",
|
||||
)
|
||||
add_route(
|
||||
QueryDefinitionView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/definition$",
|
||||
)
|
||||
add_route(
|
||||
QueryUpdateView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/update$",
|
||||
)
|
||||
add_route(
|
||||
QueryDeleteView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<query>[^\/\.]+)/-/delete$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(table_view, self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
{% if not hide_sql %}<button id="sql-format" type="button" hidden>Format SQL</button>{% endif %}
|
||||
<input type="submit" value="Run SQL"{% if canned_query_write and db_is_immutable %} disabled{% endif %}>
|
||||
{{ show_hide_hidden }}
|
||||
{% if save_query_url %}<a href="{{ save_query_url }}" class="save-query">Save query</a>{% endif %}
|
||||
{% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
|
||||
</p>
|
||||
</form>
|
||||
|
|
|
|||
71
datasette/templates/query_create.html
Normal file
71
datasette/templates/query_create.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create query{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
||||
|
||||
{% block crumbs %}
|
||||
{{ crumbs.nav(request=request, database=database) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/queries/-/insert" method="post">
|
||||
<p><label for="query-name">Name</label> <input id="query-name" name="name" type="text"></p>
|
||||
<p><label for="query-title">Title</label> <input id="query-title" name="title" type="text"></p>
|
||||
<p><label for="query-description">Description</label><br><textarea id="query-description" name="description" rows="3"></textarea></p>
|
||||
<p>
|
||||
<label><input type="radio" name="mode" value="read-only" checked> Read-only</label>
|
||||
<label><input type="radio" name="mode" value="writable"> Writable</label>
|
||||
</p>
|
||||
<p><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
<p><label for="query-parameters">Parameters</label> <input id="query-parameters" name="parameters" type="text" value="{{ parameter_names|join(', ') }}"></p>
|
||||
{% if can_publish %}
|
||||
<p><label><input type="checkbox" name="published" value="1"> Published</label></p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Analysis</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
<th scope="col">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td>{{ row.operation }}</td>
|
||||
<td>{{ row.database }}</td>
|
||||
<td>{{ row.table }}</td>
|
||||
<td>{{ row.required_permission }}</td>
|
||||
<td>{% if row.allowed is none %}{% elif row.allowed %}yes{% else %}no{% endif %}</td>
|
||||
<td>{{ row.source or "" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
|
||||
<p><input type="submit" value="Save query"{% if save_disabled %} disabled{% endif %}></p>
|
||||
</form>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -241,6 +241,14 @@ async def _build_single_action_sql(
|
|||
"),",
|
||||
]
|
||||
)
|
||||
else:
|
||||
query_parts.extend(
|
||||
[
|
||||
"anon_rules AS (",
|
||||
" SELECT NULL AS parent, NULL AS child, 0 AS allow, NULL AS reason WHERE 0",
|
||||
"),",
|
||||
]
|
||||
)
|
||||
|
||||
# Continue with the cascading logic
|
||||
query_parts.extend(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import textwrap
|
|||
|
||||
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
from datasette.resources import DatabaseResource, QueryResource, TableResource
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
|
|
@ -302,6 +302,9 @@ class QueryContext(Context):
|
|||
allow_execute_sql: bool = field(
|
||||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||
)
|
||||
save_query_url: str = field(
|
||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||
)
|
||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
named_parameter_values: dict = field(
|
||||
metadata={"help": "Dictionary of parameter names/values"}
|
||||
|
|
@ -417,6 +420,510 @@ async def database_download(request, datasette):
|
|||
)
|
||||
|
||||
|
||||
_query_name_re = re.compile(r"^[^/\.\n]+$")
|
||||
|
||||
_query_fields = {
|
||||
"sql",
|
||||
"title",
|
||||
"description",
|
||||
"description_html",
|
||||
"hide_sql",
|
||||
"fragment",
|
||||
"parameters",
|
||||
"params",
|
||||
"published",
|
||||
"on_success_message",
|
||||
"on_success_message_sql",
|
||||
"on_success_redirect",
|
||||
"on_error_message",
|
||||
"on_error_redirect",
|
||||
}
|
||||
|
||||
_query_create_fields = _query_fields | {"name", "mode", "csrftoken"}
|
||||
_query_update_fields = _query_fields
|
||||
_query_write_fields = {
|
||||
"on_success_message",
|
||||
"on_success_message_sql",
|
||||
"on_success_redirect",
|
||||
"on_error_message",
|
||||
"on_error_redirect",
|
||||
}
|
||||
|
||||
|
||||
class QueryValidationError(Exception):
|
||||
def __init__(self, message, status=400):
|
||||
self.message = message
|
||||
self.status = status
|
||||
|
||||
|
||||
def _actor_id(actor):
|
||||
if isinstance(actor, dict):
|
||||
return actor.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def _as_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, int):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"1", "true", "t", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _derived_query_parameters(sql):
|
||||
parameters = []
|
||||
seen = set()
|
||||
for parameter in derive_named_parameters(sql):
|
||||
if parameter.startswith("_"):
|
||||
raise QueryValidationError("Magic parameters are not allowed")
|
||||
if parameter not in seen:
|
||||
parameters.append(parameter)
|
||||
seen.add(parameter)
|
||||
return parameters
|
||||
|
||||
|
||||
def _coerce_query_parameters(value, derived):
|
||||
if value is None:
|
||||
return derived
|
||||
if isinstance(value, str):
|
||||
parameters = [
|
||||
parameter.strip()
|
||||
for parameter in re.split(r"[\s,]+", value)
|
||||
if parameter.strip()
|
||||
]
|
||||
elif isinstance(value, list):
|
||||
parameters = value
|
||||
else:
|
||||
raise QueryValidationError("parameters must be a list of strings")
|
||||
if not all(isinstance(parameter, str) for parameter in parameters):
|
||||
raise QueryValidationError("parameters must be a list of strings")
|
||||
if any(parameter.startswith("_") for parameter in parameters):
|
||||
raise QueryValidationError("Magic parameters are not allowed")
|
||||
if set(parameters) != set(derived):
|
||||
raise QueryValidationError("parameters must match SQL named parameters")
|
||||
return parameters
|
||||
|
||||
|
||||
async def _json_or_form_payload(request):
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.startswith("application/json"):
|
||||
body = await request.post_body()
|
||||
try:
|
||||
return json.loads(body or b"{}"), True
|
||||
except json.JSONDecodeError as e:
|
||||
raise QueryValidationError("Invalid JSON: {}".format(e))
|
||||
return await request.post_vars(), False
|
||||
|
||||
|
||||
async def _check_query_name(db, name, *, existing=False):
|
||||
if not name or not isinstance(name, str):
|
||||
raise QueryValidationError("Query name is required")
|
||||
if not _query_name_re.match(name):
|
||||
raise QueryValidationError("Invalid query name")
|
||||
if not existing and (await db.table_exists(name) or await db.view_exists(name)):
|
||||
raise QueryValidationError("Query name conflicts with a table or view")
|
||||
|
||||
|
||||
async def _analyze_user_query(datasette, db, sql, *, actor, published):
|
||||
if not sql or not isinstance(sql, str):
|
||||
raise QueryValidationError("SQL is required")
|
||||
derived = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in derived}
|
||||
try:
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
except sqlite3.DatabaseError as ex:
|
||||
raise QueryValidationError("Could not analyze query: {}".format(ex)) from ex
|
||||
|
||||
is_write = any(
|
||||
access.operation in {"insert", "update", "delete"}
|
||||
for access in analysis.table_accesses
|
||||
)
|
||||
if is_write:
|
||||
if published:
|
||||
raise QueryValidationError("Writable queries cannot be published")
|
||||
try:
|
||||
await datasette.ensure_query_write_permissions(db.name, sql, actor=actor)
|
||||
except Forbidden as ex:
|
||||
raise QueryValidationError(str(ex), status=403) from ex
|
||||
else:
|
||||
try:
|
||||
validate_sql_select(sql)
|
||||
except InvalidSql as ex:
|
||||
raise QueryValidationError(str(ex)) from ex
|
||||
return is_write, derived, analysis
|
||||
|
||||
|
||||
def _analysis_rows(analysis):
|
||||
write_actions = {
|
||||
"insert": "insert-row",
|
||||
"update": "update-row",
|
||||
"delete": "delete-row",
|
||||
}
|
||||
return [
|
||||
{
|
||||
"operation": access.operation,
|
||||
"database": access.database,
|
||||
"table": access.table,
|
||||
"required_permission": write_actions.get(access.operation, ""),
|
||||
"source": access.source,
|
||||
}
|
||||
for access in analysis.table_accesses
|
||||
]
|
||||
|
||||
|
||||
def _apply_query_data_types(data):
|
||||
typed = dict(data)
|
||||
for key in ("hide_sql", "published"):
|
||||
if key in typed:
|
||||
typed[key] = _as_bool(typed[key])
|
||||
return typed
|
||||
|
||||
|
||||
async def _prepare_query_create(datasette, request, db, data):
|
||||
invalid_keys = set(data) - _query_create_fields
|
||||
if invalid_keys:
|
||||
raise QueryValidationError("Invalid keys: {}".format(", ".join(invalid_keys)))
|
||||
|
||||
data = _apply_query_data_types(data)
|
||||
name = data.get("name")
|
||||
await _check_query_name(db, name)
|
||||
if await datasette.get_query(db.name, name) is not None:
|
||||
raise QueryValidationError("Query already exists")
|
||||
|
||||
published = _as_bool(data.get("published"))
|
||||
is_write, derived, analysis = await _analyze_user_query(
|
||||
datasette,
|
||||
db,
|
||||
data.get("sql"),
|
||||
actor=request.actor,
|
||||
published=published,
|
||||
)
|
||||
if published and not await datasette.allowed(
|
||||
action="publish-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise QueryValidationError("Permission denied: need publish-query", status=403)
|
||||
if not is_write and any(data.get(field) for field in _query_write_fields):
|
||||
raise QueryValidationError("Writable query fields require writable SQL")
|
||||
|
||||
parameters = _coerce_query_parameters(
|
||||
data.get("parameters", data.get("params")),
|
||||
derived,
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"sql": data["sql"],
|
||||
"title": data.get("title"),
|
||||
"description": data.get("description"),
|
||||
"description_html": data.get("description_html"),
|
||||
"hide_sql": _as_bool(data.get("hide_sql")),
|
||||
"fragment": data.get("fragment"),
|
||||
"parameters": parameters,
|
||||
"is_write": is_write,
|
||||
"published": published,
|
||||
"source": "user",
|
||||
"owner_id": _actor_id(request.actor),
|
||||
"on_success_message": data.get("on_success_message"),
|
||||
"on_success_message_sql": data.get("on_success_message_sql"),
|
||||
"on_success_redirect": data.get("on_success_redirect"),
|
||||
"on_error_message": data.get("on_error_message"),
|
||||
"on_error_redirect": data.get("on_error_redirect"),
|
||||
"analysis": analysis,
|
||||
}
|
||||
|
||||
|
||||
async def _prepare_query_update(datasette, request, db, existing, update):
|
||||
invalid_keys = set(update) - _query_update_fields
|
||||
if invalid_keys:
|
||||
raise QueryValidationError("Invalid keys: {}".format(", ".join(invalid_keys)))
|
||||
|
||||
update = _apply_query_data_types(update)
|
||||
sql = update.get("sql", existing["sql"])
|
||||
published = update.get("published", existing["published"])
|
||||
query_is_write = existing["is_write"]
|
||||
derived = _derived_query_parameters(sql)
|
||||
parameters = None
|
||||
|
||||
if "sql" in update:
|
||||
query_is_write, derived, _ = await _analyze_user_query(
|
||||
datasette,
|
||||
db,
|
||||
sql,
|
||||
actor=request.actor,
|
||||
published=published,
|
||||
)
|
||||
elif published and query_is_write:
|
||||
raise QueryValidationError("Writable queries cannot be published")
|
||||
if published and not existing["published"]:
|
||||
if not await datasette.allowed(
|
||||
action="publish-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise QueryValidationError(
|
||||
"Permission denied: need publish-query", status=403
|
||||
)
|
||||
|
||||
if "parameters" in update or "params" in update:
|
||||
parameters = _coerce_query_parameters(
|
||||
update.get("parameters", update.get("params")),
|
||||
derived,
|
||||
)
|
||||
elif "sql" in update:
|
||||
parameters = derived
|
||||
|
||||
if not query_is_write and any(update.get(field) for field in _query_write_fields):
|
||||
raise QueryValidationError("Writable query fields require writable SQL")
|
||||
|
||||
field_values = {
|
||||
"sql": sql,
|
||||
"title": update.get("title"),
|
||||
"description": update.get("description"),
|
||||
"description_html": update.get("description_html"),
|
||||
"hide_sql": update.get("hide_sql"),
|
||||
"fragment": update.get("fragment"),
|
||||
"parameters": parameters,
|
||||
"is_write": query_is_write,
|
||||
"published": published,
|
||||
"on_success_message": update.get("on_success_message"),
|
||||
"on_success_message_sql": update.get("on_success_message_sql"),
|
||||
"on_success_redirect": update.get("on_success_redirect"),
|
||||
"on_error_message": update.get("on_error_message"),
|
||||
"on_error_redirect": update.get("on_error_redirect"),
|
||||
}
|
||||
update_kwargs = {}
|
||||
for field, value in field_values.items():
|
||||
if field in update:
|
||||
update_kwargs[field] = value
|
||||
if parameters is not None:
|
||||
update_kwargs["parameters"] = parameters
|
||||
if "sql" in update:
|
||||
update_kwargs["is_write"] = query_is_write
|
||||
return update_kwargs
|
||||
|
||||
|
||||
class QueryListView(BaseView):
|
||||
name = "query-list"
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
page = await self.ds.allowed_resources(
|
||||
"view-query",
|
||||
request.actor,
|
||||
parent=db.name,
|
||||
limit=1000,
|
||||
)
|
||||
all_queries = await self.ds.get_queries(db.name)
|
||||
queries = [
|
||||
all_queries[resource.child]
|
||||
for resource in page.resources
|
||||
if resource.child in all_queries
|
||||
]
|
||||
return Response.json({"ok": True, "database": db.name, "queries": queries})
|
||||
|
||||
|
||||
class QueryCreateView(BaseView):
|
||||
name = "query-create"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
await self.ds.ensure_permission(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
)
|
||||
await self.ds.ensure_permission(
|
||||
action="insert-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
sql = request.args.get("sql") or ""
|
||||
analysis_error = None
|
||||
analysis_rows = []
|
||||
parameter_names = []
|
||||
if sql:
|
||||
try:
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in parameter_names}
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
rows = _analysis_rows(analysis)
|
||||
for row in rows:
|
||||
permission = row["required_permission"]
|
||||
if permission:
|
||||
row["allowed"] = await self.ds.allowed(
|
||||
action=permission,
|
||||
resource=TableResource(row["database"], row["table"]),
|
||||
actor=request.actor,
|
||||
)
|
||||
else:
|
||||
row["allowed"] = None
|
||||
analysis_rows = rows
|
||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||
analysis_error = getattr(ex, "message", str(ex))
|
||||
|
||||
return await self.render(
|
||||
["query_create.html"],
|
||||
request,
|
||||
{
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"sql": sql,
|
||||
"parameter_names": parameter_names,
|
||||
"can_publish": await self.ds.allowed(
|
||||
action="publish-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
),
|
||||
"analysis_error": analysis_error,
|
||||
"analysis_rows": analysis_rows,
|
||||
"save_disabled": bool(
|
||||
analysis_error
|
||||
or any(row["allowed"] is False for row in analysis_rows)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class QueryInsertView(BaseView):
|
||||
name = "query-insert"
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need execute-sql"], 403)
|
||||
if not await self.ds.allowed(
|
||||
action="insert-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need insert-query"], 403)
|
||||
|
||||
try:
|
||||
data, is_json = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
raise QueryValidationError("JSON must be a dictionary")
|
||||
query_data = data.get("query") if is_json else data
|
||||
if not isinstance(query_data, dict):
|
||||
raise QueryValidationError("JSON must contain a query dictionary")
|
||||
prepared = await _prepare_query_create(self.ds, request, db, query_data)
|
||||
except QueryValidationError as ex:
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
prepared.pop("analysis")
|
||||
name = prepared.pop("name")
|
||||
try:
|
||||
await self.ds.add_query(db.name, name, replace=False, **prepared)
|
||||
except sqlite3.IntegrityError as ex:
|
||||
return _error([str(ex)], 400)
|
||||
|
||||
query = await self.ds.get_query(db.name, name)
|
||||
if is_json:
|
||||
return Response.json({"ok": True, "query": query}, status=201)
|
||||
self.ds.add_message(request, "Query saved", self.ds.INFO)
|
||||
return Response.redirect(self.ds.urls.path(self.ds.urls.table(db.name, name)))
|
||||
|
||||
|
||||
class QueryDefinitionView(BaseView):
|
||||
name = "query-definition"
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
query = await self.ds.get_query(db.name, query_name)
|
||||
if query is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="view-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied"], 403)
|
||||
return Response.json({"ok": True, "query": query})
|
||||
|
||||
|
||||
class QueryUpdateView(BaseView):
|
||||
name = "query-update"
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="update-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need update-query"], 403)
|
||||
|
||||
try:
|
||||
data, _ = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
raise QueryValidationError("JSON must be a dictionary")
|
||||
invalid_keys = set(data) - {"update", "return"}
|
||||
if invalid_keys:
|
||||
raise QueryValidationError(
|
||||
"Invalid keys: {}".format(", ".join(invalid_keys))
|
||||
)
|
||||
update = data.get("update")
|
||||
if not isinstance(update, dict):
|
||||
raise QueryValidationError("JSON must contain an update dictionary")
|
||||
if "sql" in update and not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
raise QueryValidationError(
|
||||
"Permission denied: need execute-sql", status=403
|
||||
)
|
||||
update_kwargs = await _prepare_query_update(
|
||||
self.ds, request, db, existing, update
|
||||
)
|
||||
except QueryValidationError as ex:
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
await self.ds.update_query(db.name, query_name, **update_kwargs)
|
||||
if data.get("return"):
|
||||
return Response.json(
|
||||
{
|
||||
"ok": True,
|
||||
"query": await self.ds.get_query(db.name, query_name),
|
||||
}
|
||||
)
|
||||
return Response.json({"ok": True})
|
||||
|
||||
|
||||
class QueryDeleteView(BaseView):
|
||||
name = "query-delete"
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
query_name = tilde_decode(request.url_vars["query"])
|
||||
existing = await self.ds.get_query(db.name, query_name)
|
||||
if existing is None:
|
||||
return _error(["Query not found: {}".format(query_name)], 404)
|
||||
if not await self.ds.allowed(
|
||||
action="delete-query",
|
||||
resource=QueryResource(db.name, query_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _error(["Permission denied: need delete-query"], 403)
|
||||
await self.ds.remove_query(db.name, query_name)
|
||||
return Response.json({"ok": True})
|
||||
|
||||
|
||||
class QueryView(View):
|
||||
async def post(self, request, datasette):
|
||||
from datasette.app import TableNotFound
|
||||
|
|
@ -741,6 +1248,11 @@ class QueryView(View):
|
|||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
)
|
||||
allow_insert_query = await datasette.allowed(
|
||||
action="insert-query",
|
||||
resource=DatabaseResource(database=database),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
show_hide_hidden = ""
|
||||
if canned_query and canned_query.get("hide_sql"):
|
||||
|
|
@ -790,6 +1302,19 @@ class QueryView(View):
|
|||
}
|
||||
)
|
||||
)
|
||||
save_query_url = None
|
||||
if (
|
||||
not canned_query
|
||||
and allow_execute_sql
|
||||
and allow_insert_query
|
||||
and is_validated_sql
|
||||
and ":_" not in sql
|
||||
):
|
||||
save_query_url = (
|
||||
datasette.urls.database(database)
|
||||
+ "/-/queries/-/create?"
|
||||
+ urlencode({"sql": sql})
|
||||
)
|
||||
|
||||
async def query_actions():
|
||||
query_actions = []
|
||||
|
|
@ -827,6 +1352,7 @@ class QueryView(View):
|
|||
show_hide_text=show_hide_text,
|
||||
editable=not canned_query,
|
||||
allow_execute_sql=allow_execute_sql,
|
||||
save_query_url=save_query_url,
|
||||
tables=await get_tables(datasette, request, db, allowed_dict),
|
||||
named_parameter_values=named_parameter_values,
|
||||
edit_sql_url=edit_sql_url,
|
||||
|
|
|
|||
|
|
@ -1285,12 +1285,56 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
|
|||
view-query
|
||||
----------
|
||||
|
||||
Actor is allowed to view (and execute) a :ref:`canned query <canned_queries>` page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
|
||||
Actor is allowed to view (and execute) a saved query page, e.g. https://latest.datasette.io/fixtures/pragma_cache_size - this includes executing :ref:`canned_queries_writable`.
|
||||
|
||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``query`` is the name of the canned query (string)
|
||||
|
||||
``query`` is the name of the query (string)
|
||||
|
||||
.. _actions_insert_query:
|
||||
|
||||
insert-query
|
||||
------------
|
||||
|
||||
Actor is allowed to create saved queries in a database.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_publish_query:
|
||||
|
||||
publish-query
|
||||
-------------
|
||||
|
||||
Actor is allowed to publish a saved read-only query so actors without ``execute-sql`` can run it.
|
||||
|
||||
``resource`` - ``datasette.resources.DatabaseResource(database)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
.. _actions_update_query:
|
||||
|
||||
update-query
|
||||
------------
|
||||
|
||||
Actor is allowed to update a saved query.
|
||||
|
||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``query`` is the name of the query (string)
|
||||
|
||||
.. _actions_delete_query:
|
||||
|
||||
delete-query
|
||||
------------
|
||||
|
||||
Actor is allowed to delete a saved query.
|
||||
|
||||
``resource`` - ``datasette.resources.QueryResource(database, query)``
|
||||
``database`` is the name of the database (string)
|
||||
|
||||
``query`` is the name of the query (string)
|
||||
|
||||
.. _actions_insert_row:
|
||||
|
||||
|
|
|
|||
|
|
@ -179,9 +179,7 @@ async def test_query_resources_come_from_internal_table():
|
|||
|
||||
page = await ds.allowed_resources("view-query", actor=None)
|
||||
|
||||
assert [(r.parent, r.child) for r in page.resources] == [
|
||||
("data", "internal_query")
|
||||
]
|
||||
assert [(r.parent, r.child) for r in page.resources] == [("data", "internal_query")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -279,3 +277,216 @@ async def test_analyze_write_query_rejects_writes_to_attached_databases():
|
|||
"insert into extra.cats (id) values (1)",
|
||||
actor={"id": "writer"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_insert_api_creates_read_only_query():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_insert_api", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/-/queries/-/insert",
|
||||
actor={"id": "root"},
|
||||
json={
|
||||
"query": {
|
||||
"name": "by_name",
|
||||
"sql": "select * from dogs where name = :name",
|
||||
"title": "By name",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["query"]["name"] == "by_name"
|
||||
assert data["query"]["parameters"] == ["name"]
|
||||
assert data["query"]["is_write"] is False
|
||||
assert data["query"]["source"] == "user"
|
||||
assert data["query"]["owner_id"] == "root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_list_and_definition_api():
|
||||
ds = Datasette(memory=True)
|
||||
ds.root_enabled = True
|
||||
ds.add_memory_database("query_list_api", name="data")
|
||||
await ds.invoke_startup()
|
||||
await ds.add_query("data", "listed", "select 1", title="Listed", published=True)
|
||||
|
||||
list_response = await ds.client.get(
|
||||
"/data/-/queries",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
definition_response = await ds.client.get(
|
||||
"/data/listed/-/definition",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["queries"][0]["name"] == "listed"
|
||||
assert definition_response.status_code == 200
|
||||
assert definition_response.json()["query"]["title"] == "Listed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_insert_api_publish_requires_publish_query():
|
||||
ds = Datasette(
|
||||
memory=True,
|
||||
default_deny=True,
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"permissions": {
|
||||
"view-database": {"id": "writer"},
|
||||
"execute-sql": {"id": "writer"},
|
||||
"insert-query": {"id": "writer"},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
ds.add_memory_database("query_publish_api", name="data")
|
||||
await ds.invoke_startup()
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/-/queries/-/insert",
|
||||
actor={"id": "writer"},
|
||||
json={"query": {"name": "public", "sql": "select 1", "published": True}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["errors"] == ["Permission denied: need publish-query"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_insert_api_creates_writable_query():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_write_api", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/-/queries/-/insert",
|
||||
actor={"id": "root"},
|
||||
json={
|
||||
"query": {
|
||||
"name": "insert_dog",
|
||||
"sql": "insert into dogs (name) values (:name)",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
query = response.json()["query"]
|
||||
assert query["is_write"] is True
|
||||
assert query["published"] is False
|
||||
assert query["parameters"] == ["name"]
|
||||
|
||||
bad_response = await ds.client.post(
|
||||
"/data/-/queries/-/insert",
|
||||
actor={"id": "root"},
|
||||
json={
|
||||
"query": {
|
||||
"name": "published_insert",
|
||||
"sql": "insert into dogs (name) values (:name)",
|
||||
"published": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert bad_response.status_code == 400
|
||||
assert bad_response.json()["errors"] == ["Writable queries cannot be published"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_update_and_delete_api():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
ds.add_memory_database("query_update_api", name="data")
|
||||
await ds.invoke_startup()
|
||||
await ds.add_query(
|
||||
"data",
|
||||
"editable",
|
||||
"select 1",
|
||||
title="Original",
|
||||
source="user",
|
||||
owner_id="root",
|
||||
)
|
||||
|
||||
update_response = await ds.client.post(
|
||||
"/data/editable/-/update",
|
||||
actor={"id": "root"},
|
||||
json={
|
||||
"update": {
|
||||
"title": "Updated",
|
||||
"description": "Fresh",
|
||||
"on_success_redirect": None,
|
||||
},
|
||||
"return": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()["query"]
|
||||
assert updated["title"] == "Updated"
|
||||
assert updated["description"] == "Fresh"
|
||||
assert updated["on_success_redirect"] is None
|
||||
|
||||
delete_response = await ds.client.post(
|
||||
"/data/editable/-/delete",
|
||||
actor={"id": "root"},
|
||||
json={},
|
||||
)
|
||||
|
||||
assert delete_response.status_code == 200
|
||||
assert delete_response.json() == {"ok": True}
|
||||
assert await ds.get_query("data", "editable") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_insert_api_rejects_magic_parameters():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
ds.add_memory_database("query_magic_api", name="data")
|
||||
await ds.invoke_startup()
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/-/queries/-/insert",
|
||||
actor={"id": "root"},
|
||||
json={"query": {"name": "magic", "sql": "select :_actor_id"}},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["errors"] == ["Magic parameters are not allowed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_query_ui_and_arbitrary_sql_save_link():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_create_ui", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
create_response = await ds.client.get(
|
||||
"/data/-/queries/-/create?sql=select+*+from+dogs",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
query_response = await ds.client.get(
|
||||
"/data/-/query?sql=select+*+from+dogs",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
|
||||
assert create_response.status_code == 200
|
||||
assert "Create query" in create_response.text
|
||||
assert "Read-only" in create_response.text
|
||||
assert "Writable" in create_response.text
|
||||
assert "required permission" in create_response.text
|
||||
assert query_response.status_code == 200
|
||||
assert "Save query" in query_response.text
|
||||
assert "/data/-/queries/-/create?sql=select+%2A+from+dogs" in query_response.text
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue