diff --git a/backend/Cargo.lock b/backend/Cargo.lock index cc6e00f..73602e1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.23" +version = "0.124.24" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 2ff4282..5c4162d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.23" +version = "0.124.24" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0037_mangas_sort_author.sql b/backend/migrations/0037_mangas_sort_author.sql new file mode 100644 index 0000000..bd4e807 --- /dev/null +++ b/backend/migrations/0037_mangas_sort_author.sql @@ -0,0 +1,51 @@ +-- Precomputed author sort key for `?sort=author`. +-- +-- The author sort used a correlated `min(lower(a.name))` subquery as the ORDER +-- BY key, evaluated per filter-matching row before LIMIT — it scaled worse than +-- the indexed date/title sorts. Materialize the same value on `mangas` so the +-- sort is a plain indexed column read. +-- +-- `sort_author` = the alphabetically-first attached author's lowercased name, +-- or NULL when the manga has no authors (kept last via NULLS LAST in the query). +-- Author names are immutable (authors are upserted by unique lowercased name and +-- never renamed), so the value only changes when the manga_authors join changes +-- — maintained by the triggers below. + +ALTER TABLE mangas ADD COLUMN sort_author text; + +CREATE INDEX mangas_sort_author_idx ON mangas (sort_author, id); + +-- Backfill from existing links. +UPDATE mangas m + SET sort_author = ( + SELECT min(lower(a.name)) + FROM manga_authors ma + JOIN authors a ON a.id = ma.author_id + WHERE ma.manga_id = m.id + ); + +CREATE OR REPLACE FUNCTION refresh_manga_sort_author(mid uuid) RETURNS void AS $$ +BEGIN + UPDATE mangas + SET sort_author = ( + SELECT min(lower(a.name)) + FROM manga_authors ma + JOIN authors a ON a.id = ma.author_id + WHERE ma.manga_id = mid + ) + WHERE id = mid; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION mangas_sort_author_on_ma_change() RETURNS trigger AS $$ +BEGIN + -- On a manga cascade-delete the UPDATE simply no-ops (row already gone). + PERFORM refresh_manga_sort_author(COALESCE(NEW.manga_id, OLD.manga_id)); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER manga_authors_sort_author_ins AFTER INSERT ON manga_authors + FOR EACH ROW EXECUTE FUNCTION mangas_sort_author_on_ma_change(); +CREATE TRIGGER manga_authors_sort_author_del AFTER DELETE ON manga_authors + FOR EACH ROW EXECUTE FUNCTION mangas_sort_author_on_ma_change(); diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index 0d5f499..1be818b 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -168,16 +168,11 @@ pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, Op SortField::Created => "created_at", SortField::Updated => "updated_at", SortField::Title => "lower(title)", - // Sorts on the alphabetically-first attached author. As an ORDER BY - // key this correlated subquery is evaluated per filter-matching row - // before LIMIT applies, so it scales worse than the indexed date/title - // sorts — revisit with a LATERAL join or precomputed sort-name column - // if the library grows large. - SortField::Author => { - "(SELECT min(lower(a.name)) \ - FROM manga_authors ma JOIN authors a ON a.id = ma.author_id \ - WHERE ma.manga_id = mangas.id)" - } + // Sorts on the alphabetically-first attached author. Precomputed into + // `mangas.sort_author` (migration 0037, maintained by triggers on + // manga_authors) and index-backed by `mangas_sort_author_idx`, so this + // is a plain column read rather than a per-row correlated subquery. + SortField::Author => "sort_author", }; let dir = match query.order { SortOrder::Asc => "ASC", diff --git a/backend/tests/api_mangas_metadata.rs b/backend/tests/api_mangas_metadata.rs index 4592277..892e2de 100644 --- a/backend/tests/api_mangas_metadata.rs +++ b/backend/tests/api_mangas_metadata.rs @@ -326,6 +326,54 @@ async fn patch_updates_status_authors_and_genres(pool: PgPool) { assert_eq!(body["genres"][0]["name"], "Drama"); } +#[sqlx::test(migrations = "./migrations")] +async fn patch_authors_updates_precomputed_sort_author(pool: PgPool) { + // The precomputed mangas.sort_author (used by ?sort=author) must track + // author edits: changing the alphabetically-first author reorders the + // author sort. Two mangas whose relative author order flips after a PATCH. + let h = common::harness(pool.clone()); + let (_, cookie) = common::register_user(&h.app).await; + + let a = id_of(&create_manga(&h.app, &cookie, json!({ "title": "A", "authors": ["Zeta"] })).await); + let _b = id_of(&create_manga(&h.app, &cookie, json!({ "title": "B", "authors": ["Mid"] })).await); + + // Initially: Mid < Zeta, so B before A. + let ids = list_titles(&h.app, "sort=author&order=asc").await; + assert_eq!(ids, vec!["B", "A"]); + + // Re-author A to "Alpha", which now sorts first. + let resp = h + .app + .clone() + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/mangas/{a}"), + json!({ "authors": ["Alpha"] }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Now A (Alpha) sorts before B (Mid) — proving sort_author was recomputed. + let ids = list_titles(&h.app, "sort=author&order=asc").await; + assert_eq!(ids, vec!["A", "B"]); +} + +async fn list_titles(app: &axum::Router, query: &str) -> Vec { + let resp = app + .clone() + .oneshot(common::get(&format!("/api/v1/mangas?{query}"))) + .await + .unwrap(); + let body = common::body_json(resp).await; + body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap().to_string()) + .collect() +} + #[sqlx::test(migrations = "./migrations")] async fn patch_404_on_unknown_id(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/package.json b/frontend/package.json index e6f311f..3e661d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.23", + "version": "0.124.24", "private": true, "type": "module", "scripts": {