diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6a04be0..907c261 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.6" +version = "0.124.7" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 6f122a0..27b5b35 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.6" +version = "0.124.7" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 41e81ea..c6a1ca8 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -157,7 +157,9 @@ async fn list( order, }; let (items, total) = repo::manga::list_cards(&state.db, &q).await?; - Ok(Json(PagedResponse::with_total(items, limit, offset, total))) + Ok(Json(PagedResponse::with_optional_total( + items, limit, offset, total, + ))) } async fn get_one( diff --git a/backend/src/api/pagination.rs b/backend/src/api/pagination.rs index 75aa721..b65466e 100644 --- a/backend/src/api/pagination.rs +++ b/backend/src/api/pagination.rs @@ -34,4 +34,18 @@ impl PagedResponse { page: PageInfo { limit, offset, total: Some(total) }, } } + + /// For handlers that compute `total` only on some pages (e.g. the first) + /// and leave it `None` elsewhere. + pub fn with_optional_total( + items: Vec, + limit: i64, + offset: i64, + total: Option, + ) -> Self { + Self { + items, + page: PageInfo { limit, offset, total }, + } + } } diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index a01894c..d6c33f9 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -163,7 +163,7 @@ const FILTER_WHERE: &str = r#" /// (`mangas_created_at_idx`, `mangas_updated_at_idx`, `mangas_title_lower_idx`) /// and the trigram GIN indexes (0005_search.sql, 0009_manga_metadata.sql) keep /// the search filter cheap as the library grows. -pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, i64)> { +pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, Option)> { // Both `col` and `dir` are interpolated from hard-coded enums, never from // request input, so this is not a SQL injection seam. The trailing `id` is // a stable tie-break that keeps pagination deterministic across rows with @@ -223,22 +223,33 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, i6 .fetch_all(pool) .await?; - let count_sql = format!( - r#" - SELECT count(*) FROM mangas - WHERE {FILTER_WHERE} - "# - ); - let (total,): (i64,) = sqlx::query_as(&count_sql) - .bind(search) - .bind(status) - .bind(&query.author_ids) - .bind(&query.genre_ids) - .bind(&query.tag_ids) - .bind(&query.cw_include) - .bind(&query.cw_exclude) - .fetch_one(pool) - .await?; + // The count reuses FILTER_WHERE — up to five correlated NOT EXISTS/unnest + // subqueries (plus a page_content_warnings join under CW filters) over the + // whole filtered set. Recomputing it on every page made pagination scale + // with catalog size. It doesn't change as the caller walks pages, so + // compute it once on the first page (offset 0) and return None thereafter; + // the pagination envelope serialises that as `total: null`. + let total = if query.offset == 0 { + let count_sql = format!( + r#" + SELECT count(*) FROM mangas + WHERE {FILTER_WHERE} + "# + ); + let (total,): (i64,) = sqlx::query_as(&count_sql) + .bind(search) + .bind(status) + .bind(&query.author_ids) + .bind(&query.genre_ids) + .bind(&query.tag_ids) + .bind(&query.cw_include) + .bind(&query.cw_exclude) + .fetch_one(pool) + .await?; + Some(total) + } else { + None + }; Ok((rows, total)) } @@ -249,7 +260,7 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, i6 pub async fn list_cards( pool: &PgPool, query: &ListQuery, -) -> AppResult<(Vec, i64)> { +) -> AppResult<(Vec, Option)> { let (rows, total) = list(pool, query).await?; let cards = cards_from_rows(pool, rows).await?; Ok((cards, total)) diff --git a/backend/tests/api_mangas.rs b/backend/tests/api_mangas.rs index 0aeefd5..d6112ee 100644 --- a/backend/tests/api_mangas.rs +++ b/backend/tests/api_mangas.rs @@ -99,6 +99,42 @@ async fn list_returns_total_count_independent_of_pagination(pool: PgPool) { assert_eq!(body["page"]["total"], 3); } +#[sqlx::test(migrations = "./migrations")] +async fn list_total_is_computed_only_on_the_first_page(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + for title in ["One Piece", "Berserk", "Vinland Saga"] { + seed(&h.app, &cookie, title).await; + } + + // Page 1 (offset 0): total is the full population. + let body0 = common::body_json( + h.app + .clone() + .oneshot(common::get("/api/v1/mangas?limit=2&offset=0")) + .await + .unwrap(), + ) + .await; + assert_eq!(body0["page"]["total"], 3); + + // Page 2 (offset 2): total is omitted (null) — the correlated count is + // not recomputed on every page. Items still paginate correctly. + let body1 = common::body_json( + h.app + .oneshot(common::get("/api/v1/mangas?limit=2&offset=2")) + .await + .unwrap(), + ) + .await; + assert!( + body1["page"]["total"].is_null(), + "total should be null past the first page, got {}", + body1["page"]["total"] + ); + assert_eq!(body1["items"].as_array().unwrap().len(), 1); +} + #[sqlx::test(migrations = "./migrations")] async fn search_via_trigram_tolerates_typos(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/package.json b/frontend/package.json index 9a7d7ca..a0d539c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.6", + "version": "0.124.7", "private": true, "type": "module", "scripts": {