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 .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+))?$",

View file

@ -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>

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
query_parts.extend(

View file

@ -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,

View file

@ -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:

View file

@ -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