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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.12"
|
version = "0.128.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.12"
|
version = "0.128.13"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
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));
|
||||||
@@ -247,10 +247,13 @@ async fn sync_genres(
|
|||||||
let genre_id = match existing {
|
let genre_id = match existing {
|
||||||
Some((id,)) => id,
|
Some((id,)) => id,
|
||||||
None => {
|
None => {
|
||||||
|
// Conflict on lower(name) (0038) not name, so a racing insert of
|
||||||
|
// a differently-cased variant resolves to the existing row
|
||||||
|
// instead of raising a unique violation.
|
||||||
let (id,): (Uuid,) = sqlx::query_as(
|
let (id,): (Uuid,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO genres (name) VALUES ($1)
|
INSERT INTO genres (name) VALUES ($1)
|
||||||
ON CONFLICT (name) DO UPDATE SET name = genres.name
|
ON CONFLICT (lower(name)) DO UPDATE SET name = genres.name
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -674,6 +674,23 @@ async fn arbitrary_genres_from_source_get_inserted(pool: PgPool) {
|
|||||||
assert_eq!(webtoons_count.0, 1, "case-insensitive lookup reuses the existing row");
|
assert_eq!(webtoons_count.0, 1, "case-insensitive lookup reuses the existing row");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn genres_reject_case_variant_duplicates_at_the_db(pool: PgPool) {
|
||||||
|
// The sequential pre-check in sync_genres dedups the common case, but two
|
||||||
|
// concurrent inserts could each miss it. The lower(name) unique index (0038)
|
||||||
|
// is the race backstop: a case variant of an existing genre must be rejected
|
||||||
|
// by the DB itself. "Action" is seeded by 0009.
|
||||||
|
let dup = sqlx::query("INSERT INTO genres (name) VALUES ('action')")
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
let err = dup.expect_err("a case-variant of a seeded genre must violate the unique index");
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("genres_name_lower_uniq") || msg.contains("unique") || msg.contains("duplicate"),
|
||||||
|
"expected a unique-violation, got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// User-attached tags (rows with non-NULL `added_by` in `manga_tags`)
|
/// User-attached tags (rows with non-NULL `added_by` in `manga_tags`)
|
||||||
/// must survive a crawler upsert. The crawler owns source-attached tags
|
/// must survive a crawler upsert. The crawler owns source-attached tags
|
||||||
/// (added_by IS NULL); user attachments are owned by the user who made
|
/// (added_by IS NULL); user attachments are owned by the user who made
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.12",
|
"version": "0.128.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user