-- 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;