Remove has_more from query list JSON - next: null signals the end

next: null (with next_url: null) is the single end-of-results signal
across the API, keeping default response keys to a minimum. The
StoredQueryPage.has_more attribute on the documented Python API is
unchanged.

Also fixes a bug this uncovered: the query list JSON next_url pointed
at the HTML page (it was built from the query list path, dropping the
.json extension) and was a relative path where the table view next_url
is absolute. It is now built from the request path and absolute, so it
preserves the requested format and can be followed directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrHZSypDfMnym1tM5XJAFZ
This commit is contained in:
Claude 2026-07-06 23:02:42 +00:00
commit e892c686c2
No known key found for this signature in database
5 changed files with 36 additions and 8 deletions

View file

@ -83,7 +83,6 @@ def stored_query_page_to_dict(page: StoredQueryPage) -> dict[str, Any]:
return {
"queries": [stored_query_to_dict(query) for query in page.queries],
"next": page.next,
"has_more": page.has_more,
"limit": page.limit,
}

View file

@ -120,9 +120,9 @@ class QueryListView(BaseView):
if key != "_next"
]
pairs.append(("_next", page.next))
next_url = "{}?{}".format(
query_list_path,
urlencode(pairs),
next_url = self.ds.absolute_url(
request,
"{}?{}".format(request.path, urlencode(pairs)),
)
current_filters = {
@ -208,7 +208,6 @@ class QueryListView(BaseView):
"queries": page.queries,
"next": page.next,
"next_url": next_url,
"has_more": page.has_more,
"limit": page.limit,
"show_private_note": any(query.is_private for query in page.queries),
"show_trusted_note": any(query.is_trusted for query in page.queries),

View file

@ -1038,7 +1038,7 @@ databases (`database`/`database_color` are null, `show_database` true).
400 `"is_write must be 0 or 1"`), `source`, `owner_id`.
- **Response** — 200:
`{"ok": true, "database", "database_color", "queries": [...], "next",
"next_url", "has_more", "limit", "show_private_note",
"next_url", "limit", "show_private_note",
"show_trusted_note", "query_list_path", "show_database",
"facets": [{title, items: [{label, count, href, active}]}],
"filters": {q, is_write, is_private, source, owner_id}}`.

View file

@ -209,7 +209,13 @@ number.
> values (previously silently clamped), and the `/-/allowed` and
> `/-/rules` debug endpoints renamed `page`/`page_size` to
> `_page`/`_size` with the same validation (400 instead of silent
> capping at 200). The `has_more`/`total` differences remain open.
> capping at 200). `has_more` has been **removed** from the query-list
> JSON — `next: null` is the single end-of-results signal everywhere,
> keeping default response keys minimal (`total` remains a debug-endpoint
> nicety). Fixing this also uncovered and fixed a bug where the query
> list's JSON `next_url` pointed at the HTML page (it dropped the `.json`
> extension) and was relative where the table `next_url` is absolute.
> §3 is now fully resolved.
| Endpoint | Mechanism | Token | Extras |
|---|---|---|---|

View file

@ -879,7 +879,7 @@ async def test_query_list_html_defaults_to_twenty_and_shows_pagination():
assert response.text.count('aria-label="Query pagination"') == 1
assert "Demo query 20" in response.text
assert "Demo query 21" not in response.text
assert 'href="/data/-/queries?_next=' in response.text
assert 'href="http://localhost/data/-/queries?_next=' in response.text
assert len(json_response.json()["queries"]) == 25
@ -3827,3 +3827,27 @@ async def test_stored_query_json_uses_parameters_not_params():
query = [q for q in listing["queries"] if q["name"] == "with_params"][0]
assert query["parameters"] == ["name", "age"]
assert "params" not in query
@pytest.mark.asyncio
async def test_query_list_json_signals_pagination_via_next_only():
ds = Datasette(memory=True)
ds.add_memory_database("query_list_next_only", name="data")
await ds.invoke_startup()
for i in range(3):
await ds.add_query(
"data",
name="q{}".format(i),
sql="select {}".format(i),
)
first = (await ds.client.get("/data/-/queries.json?_size=2")).json()
assert "has_more" not in first
assert first["next"] is not None
assert first["next_url"] is not None
# The internal test client cannot follow absolute URLs
next_path = first["next_url"].replace("http://localhost", "")
assert next_path.startswith("/data/-/queries.json?")
last = (await ds.client.get(next_path)).json()
assert "has_more" not in last
assert last["next"] is None
assert last["next_url"] is None