mirror of
https://github.com/simonw/datasette.git
synced 2026-06-05 00:26:57 +02:00
parent
310c36ae94
commit
6eee6c81e8
6 changed files with 149 additions and 22 deletions
|
|
@ -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))?$",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue