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:
+50
-12
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user