fix: enforce case-insensitive genre uniqueness
genres had only case-sensitive name UNIQUE while authors/tags use UNIQUE (lower(name)); sync_genres could race two case variants into duplicate rows (audit DB-3). Migration 0038 pre-dedups then adds UNIQUE (lower(name)); sync_genres upserts ON CONFLICT (lower(name)). Tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
45
backend/migrations/0038_genres_name_lower_unique.sql
Normal file
45
backend/migrations/0038_genres_name_lower_unique.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- Enforce case-insensitive genre uniqueness, matching `authors` and `tags`
|
||||
-- (both got `UNIQUE (lower(name))` in 0009). `genres` only had a case-SENSITIVE
|
||||
-- `name UNIQUE`, but the crawler's `sync_genres` treats genres as
|
||||
-- case-insensitive (`WHERE lower(name) = lower($1)`) and can INSERT a new row
|
||||
-- from a source string. Under a race (or across ticks) two different-cased
|
||||
-- strings — "Isekai" vs "isekai" — could both miss the pre-check and both
|
||||
-- insert, since the exact-name unique doesn't collide. That leaves duplicate
|
||||
-- genre rows for one logical genre.
|
||||
|
||||
-- Heal any pre-existing case-variant duplicates before adding the index, so
|
||||
-- `sqlx::migrate!` can't crash on a dirty table (mirrors 0031's pre-dedup).
|
||||
-- Keep the lowest id per lower(name) as canonical and repoint manga_genres.
|
||||
UPDATE manga_genres mg
|
||||
SET genre_id = d.keep_id
|
||||
FROM (
|
||||
SELECT g.id AS dup_id, k.keep_id
|
||||
FROM genres g
|
||||
JOIN (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id
|
||||
FROM genres GROUP BY lower(name)) k
|
||||
ON lower(g.name) = k.lname AND g.id <> k.keep_id
|
||||
) d
|
||||
WHERE mg.genre_id = d.dup_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM manga_genres x
|
||||
WHERE x.manga_id = mg.manga_id AND x.genre_id = d.keep_id
|
||||
);
|
||||
|
||||
-- Drop links that would have collided with the canonical link (already present).
|
||||
DELETE FROM manga_genres mg
|
||||
USING (
|
||||
SELECT g.id AS dup_id
|
||||
FROM genres g
|
||||
JOIN (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id
|
||||
FROM genres GROUP BY lower(name)) k
|
||||
ON lower(g.name) = k.lname AND g.id <> k.keep_id
|
||||
) d
|
||||
WHERE mg.genre_id = d.dup_id;
|
||||
|
||||
-- Remove the now-orphaned duplicate genre rows.
|
||||
DELETE FROM genres g
|
||||
USING (SELECT lower(name) AS lname, (array_agg(id ORDER BY id))[1] AS keep_id
|
||||
FROM genres GROUP BY lower(name)) k
|
||||
WHERE lower(g.name) = k.lname AND g.id <> k.keep_id;
|
||||
|
||||
CREATE UNIQUE INDEX genres_name_lower_uniq ON genres (lower(name));
|
||||
Reference in New Issue
Block a user