diff --git a/backend/Cargo.lock b/backend/Cargo.lock index ebbec68..cc6e00f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.124.22" +version = "0.124.23" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b5f3f5b..2ff4282 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.124.22" +version = "0.124.23" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0036_manga_content_warnings.sql b/backend/migrations/0036_manga_content_warnings.sql new file mode 100644 index 0000000..eb3e510 --- /dev/null +++ b/backend/migrations/0036_manga_content_warnings.sql @@ -0,0 +1,103 @@ +-- Denormalized manga -> content-warning set. +-- +-- The list filter previously tested each candidate manga with a correlated +-- `page_content_warnings -> pages -> chapters` join, i.e. O(mangas * pages) on +-- every filtered list AND its count. This table holds the DISTINCT union of a +-- manga's page warnings so the filter is a single indexed lookup. +-- +-- Kept in sync by triggers that recompute an affected manga's set from current +-- data (the set is tiny — at most the five moderation labels — so a +-- delete-and-reinsert per change is cheap and always correct, sidestepping the +-- cascade-ordering hazards of incremental maintenance). + +CREATE TABLE manga_content_warnings ( + manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE, + warning text NOT NULL + CHECK (warning IN ('sexual', 'nudity', 'gore', 'violence', 'disturbing')), + PRIMARY KEY (manga_id, warning) +); + +-- warning -> mangas, for the include/exclude list filters. +CREATE INDEX manga_content_warnings_warning_idx ON manga_content_warnings (warning); + +-- Recompute a single manga's warning set from the live per-page rows. +CREATE OR REPLACE FUNCTION mcw_refresh_for_manga(mid uuid) RETURNS void AS $$ +BEGIN + -- Skip when the manga is gone (e.g. mid-cascade of a manga delete) so we + -- never re-insert a row that would violate the FK / resurrect a deleted set. + IF NOT EXISTS (SELECT 1 FROM mangas WHERE id = mid) THEN + RETURN; + END IF; + DELETE FROM manga_content_warnings WHERE manga_id = mid; + INSERT INTO manga_content_warnings (manga_id, warning) + SELECT DISTINCT mid, pw.warning + FROM page_content_warnings pw + JOIN pages p ON p.id = pw.page_id + JOIN chapters c ON c.id = p.chapter_id + WHERE c.manga_id = mid; +END; +$$ LANGUAGE plpgsql; + +-- page_content_warnings changed for a page: refresh that page's manga. +CREATE OR REPLACE FUNCTION mcw_on_pcw_change() RETURNS trigger AS $$ +DECLARE + mid uuid; + pid uuid := COALESCE(NEW.page_id, OLD.page_id); +BEGIN + -- The page (and thus chapter) may already be gone when this fires as part + -- of a pages/chapters cascade; in that case the pages/chapters triggers do + -- the refresh instead, so a missing join here is harmless. + SELECT c.manga_id INTO mid + FROM pages p JOIN chapters c ON c.id = p.chapter_id + WHERE p.id = pid; + IF mid IS NOT NULL THEN + PERFORM mcw_refresh_for_manga(mid); + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER mcw_pcw_ins AFTER INSERT ON page_content_warnings + FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change(); +CREATE TRIGGER mcw_pcw_del AFTER DELETE ON page_content_warnings + FOR EACH ROW EXECUTE FUNCTION mcw_on_pcw_change(); + +-- A page was deleted (directly, or via a chapter cascade): its +-- page_content_warnings rows are already cascade-gone, so recompute from the +-- chapter's manga. If the chapter is gone too, the chapters trigger covers it. +CREATE OR REPLACE FUNCTION mcw_on_page_delete() RETURNS trigger AS $$ +DECLARE + mid uuid; +BEGIN + SELECT c.manga_id INTO mid FROM chapters c WHERE c.id = OLD.chapter_id; + IF mid IS NOT NULL THEN + PERFORM mcw_refresh_for_manga(mid); + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER mcw_page_del AFTER DELETE ON pages + FOR EACH ROW EXECUTE FUNCTION mcw_on_page_delete(); + +-- A chapter was deleted (directly, or via a manga cascade): recompute from the +-- chapter's manga. `chapters.manga_id` is on the row, so it is always available +-- even after the child pages have cascaded; the refresh no-ops when the manga +-- itself is being deleted. +CREATE OR REPLACE FUNCTION mcw_on_chapter_delete() RETURNS trigger AS $$ +BEGIN + PERFORM mcw_refresh_for_manga(OLD.manga_id); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER mcw_chapter_del AFTER DELETE ON chapters + FOR EACH ROW EXECUTE FUNCTION mcw_on_chapter_delete(); + +-- Backfill from existing per-page rows. +INSERT INTO manga_content_warnings (manga_id, warning) +SELECT DISTINCT c.manga_id, pw.warning + FROM page_content_warnings pw + JOIN pages p ON p.id = pw.page_id + JOIN chapters c ON c.id = p.chapter_id +ON CONFLICT DO NOTHING; diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index d6c33f9..0d5f499 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -144,17 +144,13 @@ const FILTER_WHERE: &str = r#" AND NOT EXISTS ( SELECT 1 FROM unnest($6::text[]) AS req(w) WHERE NOT EXISTS ( - SELECT 1 FROM page_content_warnings pw - JOIN pages p ON p.id = pw.page_id - JOIN chapters c ON c.id = p.chapter_id - WHERE c.manga_id = mangas.id AND pw.warning = req.w + SELECT 1 FROM manga_content_warnings mcw + WHERE mcw.manga_id = mangas.id AND mcw.warning = req.w ) ) AND NOT EXISTS ( - SELECT 1 FROM page_content_warnings pw - JOIN pages p ON p.id = pw.page_id - JOIN chapters c ON c.id = p.chapter_id - WHERE c.manga_id = mangas.id AND pw.warning = ANY($7::text[]) + SELECT 1 FROM manga_content_warnings mcw + WHERE mcw.manga_id = mangas.id AND mcw.warning = ANY($7::text[]) ) "#; diff --git a/backend/tests/api_manga_warnings.rs b/backend/tests/api_manga_warnings.rs index 79266df..3bc9820 100644 --- a/backend/tests/api_manga_warnings.rs +++ b/backend/tests/api_manga_warnings.rs @@ -127,6 +127,46 @@ async fn list_filters_by_content_warning(pool: PgPool) { assert_eq!(ids, want); } +#[sqlx::test(migrations = "./migrations")] +async fn denormalized_warnings_track_chapter_deletion(pool: PgPool) { + // The denormalized manga_content_warnings table must stay in sync when the + // underlying pages disappear. Deleting the only chapter (which cascade- + // deletes its pages and their page_content_warnings) must drop the manga + // from the include filter. + let h = common::harness(pool.clone()); + let gory = seed_manga(&pool, "Gory", &[&["gore"]]).await; + + // Present in the denorm table and matched by the filter. + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM manga_content_warnings WHERE manga_id = $1") + .bind(gory) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1, "warning denormalized on insert"); + assert_eq!(list_ids(&h.app, "cw_include=gore").await, vec![gory.to_string()]); + + // Delete the chapter → cascade removes pages + page_content_warnings → + // trigger recomputes the (now empty) set for this manga. + sqlx::query("DELETE FROM chapters WHERE manga_id = $1") + .bind(gory) + .execute(&pool) + .await + .unwrap(); + + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM manga_content_warnings WHERE manga_id = $1") + .bind(gory) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 0, "warning removed after chapter (and pages) deleted"); + assert!( + list_ids(&h.app, "cw_include=gore").await.is_empty(), + "manga no longer matches the include filter" + ); +} + #[sqlx::test(migrations = "./migrations")] async fn list_rejects_unknown_warning(pool: PgPool) { let h = common::harness(pool.clone()); diff --git a/frontend/package.json b/frontend/package.json index fcc8bb8..e6f311f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.124.22", + "version": "0.124.23", "private": true, "type": "module", "scripts": {