fix(api): list /modules total reflects unpaginated row count

Dashboard count cards displayed "1" for every kind regardless of how
many rows the user actually had in the library. Root cause: the list
endpoint returned `total = len(items)` AFTER pagination, so any client
that passed `limit=1` for cheap polling (Dashboard does exactly this)
got `total=1` no matter what.

Adds `ModuleRepository.count(...)` that runs the same WHERE-clause
filters as `list()` but issues `SELECT COUNT(*)` with no LIMIT/OFFSET,
and reroutes both calls through a shared `_build_filter_clause` helper
so filter shape can never drift between the two paths.

Pinned by `test_list_total_ignores_limit` — seeds 3 wildcards, queries
`?type=wildcard&limit=1`, asserts items has 1 row but total is 3.
This commit is contained in:
DumiFlex
2026-04-28 23:01:27 +03:00
parent 7f5dfc669f
commit d53648430d
3 changed files with 75 additions and 14 deletions
+50 -12
View File
@@ -199,16 +199,20 @@ class ModuleRepository:
if cur.rowcount == 0:
raise ModuleNotFound(module_id)
def list(
def _build_filter_clause(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
type: str | None,
category_id: str | None,
query: str | None,
favorites_only: bool,
) -> tuple[str, list[Any]]:
"""Build the WHERE-clause fragment + bound params shared by list/count.
Extracted so list() (paginated rows) and count() (total ignoring
pagination) apply the same filters bit-for-bit. Mismatched filters
between the two paths is the kind of bug that silently makes
Dashboard counts wrong — the helper closes that loop."""
clauses: list[str] = []
params: list[Any] = []
if type is not None:
@@ -227,10 +231,24 @@ class ModuleRepository:
params.append(f"%{escaped}%")
if favorites_only:
clauses.append("is_favorite = 1")
sql = "SELECT * FROM modules"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY updated_at DESC, id DESC"
where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
return where, params
def list(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
limit: int | None = None,
offset: int = 0,
) -> list[dict[str, Any]]:
where, params = self._build_filter_clause(
type=type, category_id=category_id, query=query,
favorites_only=favorites_only,
)
sql = "SELECT * FROM modules" + where + " ORDER BY updated_at DESC, id DESC"
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
@@ -241,6 +259,26 @@ class ModuleRepository:
rows = self._conn.execute(sql, params).fetchall()
return [_row_to_module(r) for r in rows]
def count(
self,
*,
type: str | None = None,
category_id: str | None = None,
query: str | None = None,
favorites_only: bool = False,
) -> int:
"""Total rows matching the same filters as list(), ignoring limit
and offset. Used by the API to populate `ModuleListResponse.total`
accurately even when the client passed `limit=1` for cheap polling
(Dashboard count cards do exactly this)."""
where, params = self._build_filter_clause(
type=type, category_id=category_id, query=query,
favorites_only=favorites_only,
)
sql = "SELECT COUNT(*) AS n FROM modules" + where
row = self._conn.execute(sql, params).fetchone()
return int(row["n"]) if row is not None else 0
class CategoryNotFound(LookupError):
"""Raised when a requested category id does not exist."""
+16
View File
@@ -124,6 +124,22 @@ async def test_list_search_filter(wp_client):
assert body["items"][0]["name"] == "ColorPalette"
async def test_list_total_ignores_limit(wp_client):
"""Regression: Dashboard count cards used to read 1 for every kind
because list_modules returned `total = len(items)` AFTER the LIMIT
was applied. `total` must reflect the unpaginated row count so the
SPA can poll cheaply with limit=1 yet still display the real total."""
for n in ("a", "b", "c"):
await wp_client.post("/wp/api/modules", json={
"type": "wildcard", "name": n, "description": "",
"category_id": None, "tags": [], "payload": {"options": []},
})
resp = await wp_client.get("/wp/api/modules?type=wildcard&limit=1")
body = await resp.json()
assert len(body["items"]) == 1 # paginated
assert body["total"] == 3 # but total reflects ALL rows
async def test_list_invalid_limit_400(wp_client):
resp = await wp_client.get("/wp/api/modules?limit=abc")
assert resp.status == 400
+9 -2
View File
@@ -28,11 +28,18 @@ async def list_modules(request: web.Request) -> web.Response:
return json_error("offset must be non-negative", status=400)
with db_session(request) as conn:
items = ModuleRepository(conn).list(
repo = ModuleRepository(conn)
items = repo.list(
type=type_, category_id=category_id, query=query,
favorites_only=favorites, limit=limit, offset=offset,
)
return json_ok({"items": items, "total": len(items)})
# `total` is the unpaginated count so the SPA Dashboard can show
# "Wildcards: 15" even when limit=1 was passed for cheap polling.
total = repo.count(
type=type_, category_id=category_id, query=query,
favorites_only=favorites,
)
return json_ok({"items": items, "total": total})
async def create_module(request: web.Request) -> web.Response: