Add query management API and create UI

Refs #2735
This commit is contained in:
Simon Willison 2026-05-24 22:52:06 -07:00
commit 4b5fac9cf7
7 changed files with 904 additions and 8 deletions

View file

@ -43,7 +43,18 @@ from jinja2.exceptions import TemplateNotFound
from .events import Event from .events import Event
from .column_types import SQLiteType from .column_types import SQLiteType
from .views import Context 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.index import IndexView
from .views.special import ( from .views.special import (
JsonDataView, JsonDataView,
@ -2524,6 +2535,18 @@ class Datasette:
r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)(\.(?P<format>\w+))?$",
) )
add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$") add_route(TableCreateView.as_view(self), r"/(?P<database>[^\/\.]+)/-/create$")
add_route(
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( add_route(
DatabaseSchemaView.as_view(self), DatabaseSchemaView.as_view(self),
r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$", r"/(?P<database>[^\/\.]+)/-/schema(\.(?P<format>json|md))?$",
@ -2532,6 +2555,18 @@ class Datasette:
wrap_view(QueryView, self), wrap_view(QueryView, self),
r"/(?P<database>[^\/\.]+)/-/query(\.(?P<format>\w+))?$", 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( add_route(
wrap_view(table_view, self), wrap_view(table_view, self),
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$", r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)(\.(?P<format>\w+))?$",

View file

@ -67,6 +67,7 @@
{% 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 %}
<input type="submit" value="Run SQL"{% if canned_query_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 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 %} {% if canned_query and edit_sql_url %}<a href="{{ edit_sql_url }}" class="canned-query-edit-sql">Edit SQL</a>{% endif %}
</p> </p>
</form> </form>

View 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 %}

View file

@ -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 # Continue with the cascading logic
query_parts.extend( query_parts.extend(

View file

@ -12,7 +12,7 @@ import textwrap
from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent from datasette.events import AlterTableEvent, CreateTableEvent, InsertRowsEvent
from datasette.database import QueryInterrupted from datasette.database import QueryInterrupted
from datasette.resources import DatabaseResource, QueryResource from datasette.resources import DatabaseResource, QueryResource, TableResource
from datasette.utils import ( from datasette.utils import (
add_cors_headers, add_cors_headers,
await_me_maybe, await_me_maybe,
@ -302,6 +302,9 @@ class QueryContext(Context):
allow_execute_sql: bool = field( allow_execute_sql: bool = field(
metadata={"help": "Boolean indicating if custom SQL can be executed"} 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"}) tables: list = field(metadata={"help": "List of table objects in the database"})
named_parameter_values: dict = field( named_parameter_values: dict = field(
metadata={"help": "Dictionary of parameter names/values"} 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): class QueryView(View):
async def post(self, request, datasette): async def post(self, request, datasette):
from datasette.app import TableNotFound from datasette.app import TableNotFound
@ -741,6 +1248,11 @@ class QueryView(View):
resource=DatabaseResource(database=database), resource=DatabaseResource(database=database),
actor=request.actor, actor=request.actor,
) )
allow_insert_query = await datasette.allowed(
action="insert-query",
resource=DatabaseResource(database=database),
actor=request.actor,
)
show_hide_hidden = "" show_hide_hidden = ""
if canned_query and canned_query.get("hide_sql"): 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(): async def query_actions():
query_actions = [] query_actions = []
@ -827,6 +1352,7 @@ class QueryView(View):
show_hide_text=show_hide_text, show_hide_text=show_hide_text,
editable=not canned_query, editable=not canned_query,
allow_execute_sql=allow_execute_sql, allow_execute_sql=allow_execute_sql,
save_query_url=save_query_url,
tables=await get_tables(datasette, request, db, allowed_dict), tables=await get_tables(datasette, request, db, allowed_dict),
named_parameter_values=named_parameter_values, named_parameter_values=named_parameter_values,
edit_sql_url=edit_sql_url, edit_sql_url=edit_sql_url,

View file

@ -1285,12 +1285,56 @@ Actor is allowed to view a table (or view) page, e.g. https://latest.datasette.i
view-query 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)`` ``resource`` - ``datasette.resources.QueryResource(database, query)``
``database`` is the name of the database (string) ``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: .. _actions_insert_row:

View file

@ -179,9 +179,7 @@ async def test_query_resources_come_from_internal_table():
page = await ds.allowed_resources("view-query", actor=None) page = await ds.allowed_resources("view-query", actor=None)
assert [(r.parent, r.child) for r in page.resources] == [ assert [(r.parent, r.child) for r in page.resources] == [("data", "internal_query")]
("data", "internal_query")
]
@pytest.mark.asyncio @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)", "insert into extra.cats (id) values (1)",
actor={"id": "writer"}, 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