Add global query browser

Refs #2735
This commit is contained in:
Simon Willison 2026-05-25 10:24:42 -07:00
commit 6eee6c81e8
6 changed files with 149 additions and 22 deletions

View file

@ -52,6 +52,7 @@ from .views.database import (
QueryCreateView,
QueryDeleteView,
QueryDefinitionView,
GlobalQueryListView,
QueryInsertView,
QueryListView,
QueryUpdateView,
@ -1290,7 +1291,7 @@ class Datasette:
async def list_queries(
self,
database,
database=None,
*,
actor=None,
limit=50,
@ -1310,16 +1311,40 @@ class Datasette:
include_is_private=include_private,
)
params = dict(allowed_params)
params.update({"query_database": database, "limit": limit + 1})
params.update({"limit": limit + 1})
sort_key_sql = "lower(coalesce(nullif(q.title, ''), q.name))"
where_clauses = ["q.database_name = :query_database"]
where_clauses = []
order_by = "q.database_name, sort_key, q.name"
if database is not None:
params["query_database"] = database
where_clauses.append("q.database_name = :query_database")
order_by = "sort_key, q.name"
if cursor:
try:
components = urlsafe_components(cursor)
except ValueError:
components = []
if len(components) == 2:
if database is None and len(components) == 3:
where_clauses.append("""
(
q.database_name > :cursor_database
OR (
q.database_name = :cursor_database
AND (
{sort_key_sql} > :cursor_sort_key
OR (
{sort_key_sql} = :cursor_sort_key
AND q.name > :cursor_name
)
)
)
)
""".format(sort_key_sql=sort_key_sql))
params["cursor_database"] = components[0]
params["cursor_sort_key"] = components[1]
params["cursor_name"] = components[2]
elif database is not None and len(components) == 2:
where_clauses.append("""
(
{sort_key_sql} > :cursor_sort_key
@ -1368,13 +1393,14 @@ class Datasette:
ON allowed.parent = q.database_name
AND allowed.child = q.name
WHERE {where}
ORDER BY sort_key, q.name
ORDER BY {order_by}
LIMIT :limit
""".format(
allowed_sql=allowed_sql,
private_select=private_select,
sort_key_sql=sort_key_sql,
where=" AND ".join(where_clauses),
where=" AND ".join(where_clauses) or "1 = 1",
order_by=order_by,
),
params,
)
@ -1394,10 +1420,17 @@ class Datasette:
next_token = None
if has_more and rows:
last_row = rows[-1]
next_token = "{},{}".format(
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
if database is None:
next_token = "{},{},{}".format(
tilde_encode(last_row["database_name"]),
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
else:
next_token = "{},{}".format(
tilde_encode(last_row["sort_key"]),
tilde_encode(last_row["name"]),
)
return {
"queries": queries,
"next": next_token,
@ -2651,6 +2684,10 @@ class Datasette:
JumpView.as_view(self),
r"/-/jump(\.(?P<format>json))?$",
)
add_route(
GlobalQueryListView.as_view(self),
r"/-/queries(\.(?P<format>json))?$",
)
add_route(
InstanceSchemaView.as_view(self),
r"/-/schema(\.(?P<format>json|md))?$",

View file

@ -1,8 +1,8 @@
{% extends "base.html" %}
{% block title %}{{ database }}: queries{% endblock %}
{% block title %}{% if database %}{{ database }}: {% endif %}queries{% endblock %}
{% block body_class %}query-list db-{{ database|to_css_class }}{% endblock %}
{% block body_class %}query-list{% if database %} db-{{ database|to_css_class }}{% endif %}{% endblock %}
{% block crumbs %}
{{ crumbs.nav(request=request, database=database) }}
@ -12,7 +12,7 @@
<h1>Queries</h1>
<form action="{{ urls.database(database) }}/-/queries" method="get">
<form action="{{ query_list_path }}" method="get">
<p>
<label for="query-search">Search</label>
<input id="query-search" type="search" name="q" value="{{ filters.q }}">
@ -38,7 +38,10 @@
<ul class="bullets">
{% for query in queries %}
<li>
<a href="{{ urls.query(database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if show_database %}
<a href="{{ urls.database(query.database) }}">{{ query.database }}</a>:
{% endif %}
<a href="{{ urls.query(query.database, query.name) }}{% if query.fragment %}#{{ query.fragment }}{% endif %}" title="{{ query.description or query.sql }}">{{ query.title or query.name }}</a>{% if query.private %} 🔒{% endif %}
{% if query.is_write %}<span>Writable</span>{% endif %}
{% if query.is_published %}<span>Published</span>{% endif %}
</li>

View file

@ -967,8 +967,14 @@ class ExecuteWriteView(BaseView):
class QueryListView(BaseView):
name = "query-list"
async def database_name(self, request):
return (await self.ds.resolve_database(request)).name
def query_list_path(self, database):
return self.ds.urls.database(database) + "/-/queries"
async def get(self, request):
db = await self.ds.resolve_database(request)
database = await self.database_name(request)
format_ = request.url_vars.get("format") or "html"
try:
limit = _query_list_limit(request.args.get("_size"))
@ -980,7 +986,7 @@ class QueryListView(BaseView):
return _error([ex.message], ex.status)
page = await self.ds.list_queries(
db.name,
database,
actor=request.actor,
limit=limit,
cursor=request.args.get("_next"),
@ -991,6 +997,7 @@ class QueryListView(BaseView):
owner_id=request.args.get("owner_id") or None,
include_private=True,
)
query_list_path = self.query_list_path(database)
next_url = None
if page["next"]:
pairs = [
@ -1002,18 +1009,20 @@ class QueryListView(BaseView):
]
pairs.append(("_next", page["next"]))
next_url = "{}?{}".format(
self.ds.urls.database(db.name) + "/-/queries",
query_list_path,
urlencode(pairs),
)
data = {
"ok": True,
"database": db.name,
"database": database,
"queries": page["queries"],
"next": page["next"],
"next_url": next_url,
"has_more": page["has_more"],
"limit": page["limit"],
"query_list_path": query_list_path,
"show_database": database is None,
"filters": {
"q": request.args.get("q") or "",
"is_write": request.args.get("is_write") or "",
@ -1031,6 +1040,16 @@ class QueryListView(BaseView):
)
class GlobalQueryListView(QueryListView):
name = "global-query-list"
async def database_name(self, request):
return None
def query_list_path(self, database):
return self.ds.urls.path("/-/queries")
class QueryCreateView(BaseView):
name = "query-create"
has_json_alternate = False

View file

@ -505,12 +505,13 @@ The JSON write API
Datasette provides a write API for JSON data. This is a POST-only API that requires an authenticated API token, see :ref:`CreateTokenView`. The token will need to have the specified :ref:`authentication_permissions`.
.. _GlobalQueryListView:
.. _QueryListView:
Listing saved queries
~~~~~~~~~~~~~~~~~~~~~
``GET /<database>/-/queries.json`` returns saved query definitions the actor can view. Use ``?_size=50`` to set the page size and ``?_next=...`` with the cursor returned by the previous page to fetch the next page.
``GET /-/queries.json`` returns saved query definitions across every database that the actor can view. ``GET /<database>/-/queries.json`` returns saved query definitions for a specific database. Use ``?_size=50`` to set the page size and ``?_next=...`` with the cursor returned by the previous page to fetch the next page.
.. _QueryCreateView:

View file

@ -210,7 +210,7 @@ JSON endpoints should follow Datasette's existing write API style: use `POST` pl
Endpoints:
- `GET /{database}/-/queries` shows a searchable HTML query browser. `GET /{database}/-/queries.json` returns query definitions the actor can view, using cursor pagination with `_next` and `_size`.
- `GET /-/queries` and `GET /{database}/-/queries` show searchable HTML query browsers. `GET /-/queries.json` lists query definitions across every database the actor can view; `GET /{database}/-/queries.json` scopes that list to one database. Both JSON endpoints use cursor pagination with `_next` and `_size`.
- `POST /{database}/-/queries/-/insert` creates a query.
- `GET /{database}/{query}/-/definition` returns one query definition without executing it.
- `POST /{database}/{query}/-/update` updates one query.
@ -366,7 +366,7 @@ await datasette.list_queries(
)
```
`list_queries()` should return a bounded page shaped like `{"queries": [...], "next": "...", "has_more": true, "limit": 50}`. The `next` value is an opaque cursor token, not an offset.
`list_queries()` should return a bounded page shaped like `{"queries": [...], "next": "...", "has_more": true, "limit": 50}`. The `next` value is an opaque cursor token, not an offset. Passing `database=None` lists visible queries across all live databases, still filtered through `view-query` permission SQL.
`update_query()` should use an internal sentinel default such as `UNCHANGED = object()` so callers can distinguish "leave this column alone" from "set this column to `NULL`":
@ -392,7 +392,7 @@ The save form should call `POST /{database}/-/queries/-/insert` and default to `
If the actor also has `publish-query`, include a publish control. The UI copy should make it clear that publishing allows people without arbitrary SQL permission to run this query.
On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination.
On `/{database}`, show a preview of the first 5 visible queries using `list_queries(..., limit=5)`. If the page has `has_more`, show a link to `/{database}/-/queries` rather than rendering hundreds or thousands of query links inline. The full `/{database}/-/queries` page provides search, filters, and cursor pagination. The global `/-/queries` page reuses the same interface and shows the database for each query.
## Dedicated create query UI

View file

@ -457,6 +457,73 @@ async def test_query_list_search_filter_and_html():
]
@pytest.mark.asyncio
async def test_global_query_list_api_and_html():
ds = Datasette(memory=True)
ds.root_enabled = True
ds.add_memory_database("query_list_global_alpha", name="alpha")
ds.add_memory_database("query_list_global_beta", name="beta")
await ds.invoke_startup()
await ds.add_query(
"alpha",
"alpha_first",
"select 1",
title="Alpha first",
is_published=True,
source="user",
owner_id="root",
)
await ds.add_query(
"alpha",
"alpha_second",
"select 2",
title="Alpha second",
is_published=True,
source="user",
owner_id="root",
)
await ds.add_query(
"beta",
"beta_first",
"select 3",
title="Beta first",
is_published=True,
source="user",
owner_id="root",
)
list_response = await ds.client.get(
"/-/queries.json?_size=2",
actor={"id": "root"},
)
next_response = await ds.client.get(
"/-/queries.json?_size=2&_next={}".format(list_response.json()["next"]),
actor={"id": "root"},
)
html_response = await ds.client.get(
"/-/queries?q=Beta",
actor={"id": "root"},
)
assert list_response.status_code == 200
assert [
(query["database"], query["name"]) for query in list_response.json()["queries"]
] == [
("alpha", "alpha_first"),
("alpha", "alpha_second"),
]
assert list_response.json()["next"]
assert [
(query["database"], query["name"]) for query in next_response.json()["queries"]
] == [
("beta", "beta_first"),
]
assert html_response.status_code == 200
assert 'href="/beta">beta</a>:' in html_response.text
assert "Beta first" in html_response.text
assert "Alpha first" not in html_response.text
@pytest.mark.asyncio
async def test_query_insert_api_publish_requires_publish_query():
ds = Datasette(