diff --git a/datasette/views/table.py b/datasette/views/table.py index 0a908c69..7d085bf8 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -2140,10 +2140,10 @@ async def table_view_data( count_sql = f"select count(*) {from_sql}" # Handle pagination driven by ?_next= - _next = _next or request.args.get("_next") + _next = _next if _next is not None else request.args.get("_next") offset = "" - if _next: + if _next is not None: sort_value = None if is_view: # _next is an offset @@ -2397,7 +2397,7 @@ async def table_view_data( data = { "ok": True, - "next": next_value and str(next_value) or None, + "next": None if next_value is None else str(next_value), "next_url": next_url, } data.update( diff --git a/tests/test_table_api.py b/tests/test_table_api.py index 58a5ddc3..483d42f1 100644 --- a/tests/test_table_api.py +++ b/tests/test_table_api.py @@ -366,6 +366,49 @@ async def test_page_size_zero(ds_client): assert None is response.json()["next_url"] +def test_paginate_table_with_empty_string_primary_key(): + # path_from_row_pks() returns "" for a row whose only primary key + # value is the empty string, and "" is a legitimate _next= cursor. + # The "next" key and the _next= handling both used a truthiness + # check, so the token was dropped and pagination never advanced. + with make_app_client( + memory=True, + extra_databases={ + "empty_pk": ( + "CREATE TABLE t (id TEXT PRIMARY KEY);" + "INSERT INTO t (id) VALUES ('a'), (''), ('c'), ('d'), ('e');" + ) + }, + ) as client: + fetched = [] + path = "/empty_pk/t.json?_size=1" + page = 0 + while path: + page += 1 + response = client.get(path) + assert response.status == 200 + data = response.json + fetched.extend(row["id"] for row in data["rows"]) + if data["next_url"]: + # next_url and next agree, per docs/json_api.rst + assert ( + urllib.parse.urlencode({"_next": data["next"]}) in data["next_url"] + ) + path = data["next_url"] + if path: + path = path.replace("http://localhost", "") + assert page < 30, "Possible infinite loop detected" + + # Every row exactly once + assert 5 == len(fetched) + assert ["", "a", "c", "d", "e"] == sorted(fetched) + assert 5 == page + # And the final page has both keys null, as documented + last = client.get("/empty_pk/t.json?_size=100").json + assert None is last["next"] + assert None is last["next_url"] + + @pytest.mark.asyncio async def test_paginate_compound_keys(ds_client): fetched = []