Repository navigation
Keep every filter in pagination links; fix previous past the end - #27
Conversation
The links were built from the model's field names, so ec_number, protein and uniprot came out as clean_ec_number, protein_name and uniprot_id, which the endpoint ignores. Following next silently dropped those filters. Build the links from the request's own query parameters.
matthewberry
left a comment
There was a problem hiding this comment.
I found no correctness bugs in the new link-building code. The main issue is that the round-trip test can pass without exercising the followed link (inline below). I haven't run the suite, and none of these were reproduced.
One comment is outside the diff: /typeahead (app/routers/search.py ~L566) still maps model fields to query names by hand, which is the pattern that caused this bug. A filter added to both endpoints later would carry through /search links automatically but be dropped from /typeahead links. Sharing one request-based helper between the two would close that gap.
| params = parse_qs(urlsplit(links[name]).query) | ||
| assert params == {**original, "limit": ["10"], "offset": [offset]}, name | ||
|
|
||
| follow(client, links[name]) |
There was a problem hiding this comment.
This can pass without testing the followed link. If the link is rejected before it reaches the DB (a 422 from a renamed parameter, or a 400 from parse_query_params), fetchval isn't called again, so call_args still holds the original request's query and the comparison passes. Suggest calling db.fetchval.reset_mock() before each follow and asserting follow(...).status_code == 200.
There was a problem hiding this comment.
Confirmed and fixed in a491ab7. I reproduced it: following a link that returns 422 left call_args on the original request, and the comparison passed. follow() now resets both DB mocks first and asserts a 200, so a rejected link fails the test, and so does one that never reaches the DB, since call_args is then None. I checked that follow() raises on a limit=-1 link.
|
|
||
|
|
||
| def where_clause(db): | ||
| query = db.fetchval.call_args.args[0] |
There was a problem hiding this comment.
This compares only the COUNT query (fetchval). The data query (db.fetch) that returns the page is never compared, so a change to it alone wouldn't fail this test.
There was a problem hiding this comment.
Confirmed and fixed in a491ab7. This also hid a gap: ordering only changes the data query's ORDER BY, so the round trip never checked it. The comparison now covers the full SQL and bound arguments of both the COUNT (fetchval) and the data query (fetch), with only LIMIT/OFFSET normalized. I checked that dropping ordering from the request changes the data query but not the COUNT.
| query_params["format"] = params.format | ||
| # Repeat the caller's own query parameters, whose names differ from the | ||
| # model's field names (ec_number vs clean_ec_number, for example) | ||
| query_params = [ |
There was a problem hiding this comment.
This now copies every parameter the caller sent, not just /search filters. With ?organsim=Homo%20sapiens&_=1729600000&token=abc, all three are carried into next/previous: the misspelled filter stays silently ignored on every page, and anything token-like ends up in the JSON body and in logs. Consider limiting this to the endpoint's known query names (aliases included).
There was a problem hiding this comment.
Declining this one. Copying every parameter is intentional: a next link means "the same request, next page", and dropping parameters would quietly rewrite what the caller asked for.
On the two risks:
- Tokens in logs: uvicorn's access log already records the full query string of every request; the current production pod has those lines for
/search. A token in the URL is logged on the first request whether or not it's echoed. The links go back only to the client that sent the request, and nothing in the app logs them. - Misspelled filters:
organsim=is already ignored on page 1. Carrying it through keeps every page consistent with the first, and leaves the typo visible in the link rather than silently removed.
| @@ -337,41 +337,34 @@ async def get_data( | |||
| if request: | |||
| base_url = str(request.url).split("?")[0] | |||
There was a problem hiding this comment.
Starlette already does this: str(request.url.include_query_params(offset=next_offset, limit=current_limit)) keeps the multi-valued params and replaces offset and limit. That would remove the base_url split, the list concatenation and the urlencode in both blocks.
There was a problem hiding this comment.
Done in a491ab7. I checked Starlette 0.46.2's source first: include_query_params re-parses the query with keep_blank_values=True, and MultiDict.update replaces only the given keys, so repeated parameters like organism survive. Both endpoints now use one page_links(request, offset, limit, total) helper built on it.
|
|
||
| # Previous page link if not on first page | ||
| if current_offset > 0: | ||
| prev_offset = max(0, current_offset - current_limit) |
There was a problem hiding this comment.
This predates the PR, but it's in this block: when offset is already past total_count, previous is offset - limit, which is another empty page. For example, offset=100000&limit=10 with 36,682 rows sends a client back through about 6,300 empty pages. Clamping to max(0, min(current_offset - current_limit, total_count - current_limit)) would fix it.
There was a problem hiding this comment.
Confirmed and fixed in a491ab7, using your clamp. Reproduced first: offset=100000&limit=10 with 36,682 matches returned no rows and previous = offset=99990. /typeahead had the same bug in its own copy (max(0, offset - limit)); both now go through the shared helper. Tests cover past the end on both endpoints (→ last page) and past the end of an empty result (→ offset=0), and fail with the old expression.
Build next and previous links with request.url.include_query_params in one page_links helper, replacing /search's list building and /typeahead's hand-written name mapping. Past the end, previous now points at the last page instead of another empty one. The round-trip tests now reset the DB mocks before following a link, assert it returned 200, and compare the data query as well as the COUNT, so a link that never reaches the DB or loses ordering fails.
|
Thanks. Replies are inline; everything except the parameter filtering is in a491ab7.
I mutation-checked the tests: with |
Problem
/api/v1/searchbuilds itsnextandpreviouslinks fromCLEANSearchQueryParams.model_dump(), so the parameters use the model's field names rather than the query parameter names. Three differ, and the endpoint ignores the model names:ec_numberclean_ec_numberproteinprotein_nameuniprotuniprot_idSo following
nextsilently drops those filters. From production today:That
nextlink searches the whole table instead of the 36,682 matches, and it also runs an unfilteredCOUNT. I confirmed the dropped filters locally withTestClientand a fake DB rather than following the link on production.Fix
A new
page_links(request, offset, limit, total)helper builds both links as the request's own URL withoffsetandlimitreplaced (request.url.include_query_params), so every parameter keeps the name the caller sent, including repeated ones likeorganism. Unknown parameters are carried through too, deliberately: anextlink is the same request, next page./typeaheadnow uses the same helper. Its hand-written mapping from model fields to query names was correct, but it's the pattern that caused this bug, and a filter added to both endpoints later would only have carried through/searchlinks.Also fixed in the same code, on both endpoints: past the end of the results,
previouspointed at another empty page (offset=100000&limit=10with 36,682 matches gaveprevious=offset=99990). It now goes to the last page, or tooffset=0when there are no results.Tests
tests/test_search_pagination_links.py:/searchand/typeahead, sends one of every filter (including a repeatedorganismon/search) and checks thatnextandpreviouscontain exactly those parameters plus the newoffset/limit. Following each link must return 200 and run the same COUNT and data queries, SQL and bound arguments, as the original request, with onlyLIMIT/OFFSETnormalized. The DB mocks are reset before each follow, so a link that never reaches the DB fails.limit,nextcarries the default page size (5000)previousgoes to the last page on both endpoints, and tooffset=0when there are no resultsMutation checks: with
main's link code, 5 of the 6 tests fail (the typeahead round trip passes, since its old mapping was correct); without the clamp, the three past-the-end tests fail; a link that dropsorderingfails. Full suite: 31 passed.ruff check app testsreports the same 4 errors asmain.Not changed
The links still start with
http://, because the app sees the request behind the TLS-terminating proxy. Production answershttp://with a 301 tohttps://, so following a link works for GET clients. Emittinghttps://directly would need uvicorn's--proxy-headers/--forwarded-allow-ipsin the Dockerfile, which is a separate change.