Skip to content

Commit 5f70412

Browse files
authored
Feat (search): emit null page / total_pages when no rows on current page (#7)
`datastore_search` / `datastore_search_sql` previously omitted `page` and `total_pages` from `_links` whenever the current page had no rows (empty resource or `offset >= total`). Clients can't distinguish a forgotten field from "no current page exists". Emit explicit `null` in those cases; ints when there are rows; key still omitted only when `total` is genuinely unknown (`include_total=false`).
1 parent 75e4838 commit 5f70412

3 files changed

Lines changed: 59 additions & 50 deletions

File tree

datastore/services/read.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -226,19 +226,14 @@ def _build_pagination_links(
226226
Counter keys (added alongside the URL keys, 1-indexed):
227227
- ``page_size`` — rows per page = ``limit``; emitted whenever
228228
``limit > 0`` (a UI can always render it, even on empty pages).
229-
- ``page`` — current page = ``offset // limit + 1``.
230-
- ``total_pages`` — ``ceil(total / limit)``; omitted when total
231-
is unknown.
232-
233-
``page`` and ``total_pages`` are dropped whenever the current page
234-
has no rows — either because the resource is empty
235-
(``total == 0``) or because the caller paged past the end
236-
(``offset >= total > 0``). Reporting ``page=5 / total_pages=4``
237-
would be incoherent (no such page exists); the absence + the
238-
empty `records` array + `prev` are what let a UI recover. When
239-
``total`` is unknown (caller didn't request `include_total`) we
240-
keep ``page`` since position is still meaningful for single-page
241-
pickers.
229+
- ``page`` — current page = ``offset // limit + 1``, or
230+
``null`` when the current page has no rows (empty resource or
231+
``offset >= total``). Reporting ``page=5 / total_pages=4``
232+
would be incoherent, so we emit explicit `null` instead — UI
233+
can distinguish "no current page" from "field missing".
234+
- ``total_pages`` — ``ceil(total / limit)``, or ``null`` in the
235+
same no-rows case. Omitted entirely when ``total`` is unknown
236+
(``include_total=False``) so we don't fabricate a count.
242237
243238
All non-`offset` query params ride along on every emitted URL.
244239
@@ -269,16 +264,18 @@ def _qs(pairs: list[tuple[str, str]]) -> str:
269264
out["next"] = _qs(base_pairs + [("offset", str(offset + limit))])
270265
if limit > 0:
271266
out["page_size"] = limit
272-
# Drop `page` / `total_pages` whenever the current page has no
273-
# rows: empty resource or past-end pagination. `total is None`
274-
# means "unknown → assume there might be rows" so we still emit
275-
# `page`.
267+
# `total is None` → unknown, assume there might be rows → real ints.
268+
# Empty resource / past-end → emit explicit `null` so clients can
269+
# distinguish "no current page exists" from "field forgotten".
276270
has_rows_on_page = total is None or (total > 0 and offset < total)
277-
if limit > 0 and has_rows_on_page:
278-
out["page"] = offset // limit + 1
279-
if total is not None:
280-
# ceil division without importing math
281-
out["total_pages"] = (total + limit - 1) // limit
271+
if limit > 0:
272+
if has_rows_on_page:
273+
out["page"] = offset // limit + 1
274+
if total is not None:
275+
out["total_pages"] = (total + limit - 1) // limit # ceil div
276+
elif total is not None:
277+
out["page"] = None
278+
out["total_pages"] = None
282279
return out
283280

284281

@@ -323,8 +320,12 @@ def _link_for(target_offset: int) -> str:
323320
if limit > 0:
324321
out["page_size"] = limit
325322
has_rows_on_page = total is None or (total > 0 and offset < total)
326-
if limit > 0 and has_rows_on_page:
327-
out["page"] = offset // limit + 1
328-
if total is not None:
329-
out["total_pages"] = (total + limit - 1) // limit
323+
if limit > 0:
324+
if has_rows_on_page:
325+
out["page"] = offset // limit + 1
326+
if total is not None:
327+
out["total_pages"] = (total + limit - 1) // limit
328+
elif total is not None:
329+
out["page"] = None
330+
out["total_pages"] = None
330331
return out

tests/test_datastore_search.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,19 +492,21 @@ def test_csv_quotes_values_with_special_chars(
492492

493493

494494
def test_search_objects_response_includes_links(client: TestClient) -> None:
495-
"""Empty-table case (placeholder engine, total=0): only `start` is
496-
emitted — `next` / `prev` don't apply and the page counters are
497-
suppressed when there's nothing to page through. Scheme + host
498-
carried from the request (TestClient uses `http://testserver`)."""
495+
"""Empty-table case (placeholder engine, total=0): `start` and
496+
`page_size` are emitted; `page` / `total_pages` come through as
497+
explicit `null` so clients can tell "no current page" from "field
498+
missing". `next` / `prev` don't apply."""
499499
response = client.get(SEARCH_URL, params=_params())
500500

501501
assert response.status_code == 200
502502
links = response.json()["result"]["_links"]
503-
assert set(links) == {"start", "page_size"}
503+
assert set(links) == {"start", "page_size", "page", "total_pages"}
504504
assert links["start"].startswith("http://testserver/api/3/action/datastore_search")
505505
assert "offset" not in links["start"]
506506
assert f"resource_id={_RESOURCE_ID}" in links["start"]
507507
assert links["page_size"] == 100 # default limit
508+
assert links["page"] is None
509+
assert links["total_pages"] is None
508510

509511

510512
def test_search_links_prev_emitted_on_inner_page(client: TestClient) -> None:
@@ -537,17 +539,20 @@ def test_search_links_preserve_other_query_params(client: TestClient) -> None:
537539
links = response.json()["result"]["_links"]
538540
for v in links.values():
539541
if not isinstance(v, str):
540-
continue # `page` / `total_pages` are ints
542+
continue # `page_size` is int; `page`/`total_pages` may be null
541543
assert "filters=" in v
542544
assert "sort=" in v
543545
assert "fields=" in v
544546

545547

546548
def test_search_lists_format_also_includes_links(client: TestClient) -> None:
547549
"""`records_format=lists` is still a JSON envelope, so `_links` is
548-
present (placeholder engine, empty table: `start` + `page_size`)."""
550+
present (placeholder engine, empty table: `start` + `page_size` +
551+
null page counters)."""
549552
response = client.get(SEARCH_URL, params=_params(records_format="lists"))
550553

551554
assert response.status_code == 200
552555
links = response.json()["result"]["_links"]
553-
assert set(links) == {"start", "page_size"}
556+
assert set(links) == {"start", "page_size", "page", "total_pages"}
557+
assert links["page"] is None
558+
assert links["total_pages"] is None

tests/test_read_service.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,17 @@ def test_tsv_records_string_is_empty_when_engine_yields_no_rows() -> None:
153153

154154

155155
def test_links_present_on_every_format() -> None:
156-
"""Placeholder engine returns total=0 → no rows, so `page` and
157-
`total_pages` are suppressed. `page_size` is always present when
158-
`limit > 0` since a UI can render it even on an empty page."""
156+
"""Placeholder engine returns total=0 → no rows on the page.
157+
`page` / `total_pages` come through as explicit `null` so clients
158+
can distinguish "no current page" from "field missing".
159+
`page_size` is always present when `limit > 0`."""
159160
for fmt in ("objects", "lists", "csv", "tsv"):
160161
body = _call(data_dict_overrides={"records_format": fmt})
161162
links = body["result"]["_links"]
162-
assert set(links) == {"start", "page_size"}, fmt
163+
assert set(links) == {"start", "page_size", "page", "total_pages"}, fmt
163164
assert links["page_size"] == 100 # default limit
165+
assert links["page"] is None
166+
assert links["total_pages"] is None
164167

165168

166169
# --- _build_pagination_links: URL surgery + presence rules -----------------
@@ -258,27 +261,27 @@ def test_links_omit_prev_at_first_page() -> None:
258261
assert "next" in links
259262

260263

261-
def test_links_drop_page_counters_on_empty_resource() -> None:
262-
"""No rows on the current page (empty resource) → suppress `page`
263-
and `total_pages`. `page_size` rides along since a UI can still
264-
render it; `start` is the only meaningful nav URL."""
264+
def test_links_null_page_counters_on_empty_resource() -> None:
265+
"""Empty resource → `page` / `total_pages` are explicit `null`
266+
(not omitted). `page_size` and `start` are present as usual."""
265267
links = _build_pagination_links(
266268
"/path", limit=10, offset=0, total=0,
267269
)
268-
assert set(links) == {"start", "page_size"}
270+
assert set(links) == {"start", "page_size", "page", "total_pages"}
269271
assert links["page_size"] == 10
272+
assert links["page"] is None
273+
assert links["total_pages"] is None
270274

271275

272-
def test_links_drop_page_counters_when_offset_past_total() -> None:
273-
"""Caller paged past the end (`offset >= total`) → counters lie
274-
about position, so they're dropped. `prev` remains so the UI can
275-
walk back to a real page; the empty `records` array signals the
276-
overshoot."""
276+
def test_links_null_page_counters_when_offset_past_total() -> None:
277+
"""Caller paged past the end (`offset >= total`) → counters would
278+
lie about position, so they're emitted as explicit `null`. `prev`
279+
remains so the UI can walk back to a real page."""
277280
links = _build_pagination_links(
278281
"/path", limit=100, offset=400, total=302,
279282
)
280-
assert "page" not in links
281-
assert "total_pages" not in links
283+
assert links["page"] is None
284+
assert links["total_pages"] is None
282285
assert "prev" in links # offset > 0
283286
assert "next" not in links # nothing past the end
284287

0 commit comments

Comments
 (0)