feat(page-tags): normalize page_tags onto the shared tags table

page_tags previously stored the tag inline as free-form text — the lone
un-normalized tag concept, while authors/genres/manga-tags all went
through a lookup table + FK. Fold page tags into the SAME shared `tags`
table that manga_tags uses: `page_tags.tag` becomes `page_tags.tag_id`
referencing `tags(id)`. Manga tags and page tags now share one global
vocabulary.

The HTTP contract is unchanged — the API still speaks tag *names*; the
repo resolves name<->id via `repo::tag::upsert_by_name` (the manga-tag
helper) and keeps the stricter page-tag `normalize_tag` at the boundary.
Frontend, DTOs, and response shapes are untouched. User-visible effect:
page-tag names now appear in the manga-tag autocomplete and vice versa.

Migration 0024 backfills `tags` from existing inline values (deduping
case-insensitively), repoints rows, swaps the unique/secondary indexes
to tag_id, and drops the inline column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 17:44:38 +02:00
parent 33f684887d
commit f30600162e
7 changed files with 182 additions and 41 deletions

View File

@@ -1,10 +1,14 @@
//! Per-user, per-page tag persistence.
//!
//! Same plain-function pattern as the rest of `repo`. Idempotent
//! upserts via `ON CONFLICT DO NOTHING` (the caller distinguishes
//! 201/200 from the returned `bool`), FK-violation remap to NotFound so
//! the handler can return 404 when the page was deleted between an
//! existence check and the insert.
//! Same plain-function pattern as the rest of `repo`. Tag names resolve
//! to the shared `tags` table (the same one `manga_tags` uses) via
//! [`crate::repo::tag::upsert_by_name`]; `page_tags` links rows by
//! `tag_id` (see migration 0024). The API still speaks tag *names* — the
//! name<->id resolution lives entirely here. Idempotent upserts via
//! `ON CONFLICT DO NOTHING` (the caller distinguishes 201/200 from the
//! returned `bool`), FK-violation remap to NotFound so the handler can
//! return 404 when the page was deleted between an existence check and
//! the insert.
use sqlx::PgPool;
use uuid::Uuid;
@@ -33,26 +37,30 @@ impl Order {
}
}
/// Insert a normalized tag for `(user_id, page_id)`. Returns `true` if
/// a new row was inserted (handler → 201), `false` if the tag was
/// already present (handler → 200).
/// Insert a tag for `(user_id, page_id)`. The tag name is resolved to
/// (or created in) the shared `tags` table first — the same table and
/// `upsert_by_name` helper the manga-tag path uses — then linked by
/// `tag_id`. Returns `true` if a new link was inserted (handler → 201),
/// `false` if the tag was already present (handler → 200).
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
page_id: Uuid,
tag: &str,
) -> AppResult<bool> {
let mut tx = pool.begin().await?;
let tag_row = crate::repo::tag::upsert_by_name(&mut *tx, tag).await?;
let result = sqlx::query(
r#"
INSERT INTO page_tags (user_id, page_id, tag)
INSERT INTO page_tags (user_id, page_id, tag_id)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, page_id, tag) DO NOTHING
ON CONFLICT (user_id, page_id, tag_id) DO NOTHING
"#,
)
.bind(user_id)
.bind(page_id)
.bind(tag)
.execute(pool)
.bind(tag_row.id)
.execute(&mut *tx)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
@@ -60,9 +68,13 @@ pub async fn upsert(
}
other => AppError::Database(other),
})?;
tx.commit().await?;
Ok(result.rows_affected() > 0)
}
/// Remove a tag from `(user_id, page_id)`. The tag arrives as a name; we
/// resolve it to the shared `tags` row case-insensitively in a subquery
/// so a no-op delete (unknown name) simply matches nothing.
pub async fn remove(
pool: &PgPool,
user_id: Uuid,
@@ -70,7 +82,12 @@ pub async fn remove(
tag: &str,
) -> AppResult<()> {
sqlx::query(
"DELETE FROM page_tags WHERE user_id = $1 AND page_id = $2 AND tag = $3",
r#"
DELETE FROM page_tags
WHERE user_id = $1
AND page_id = $2
AND tag_id = (SELECT id FROM tags WHERE lower(name) = lower($3))
"#,
)
.bind(user_id)
.bind(page_id)
@@ -89,10 +106,11 @@ pub async fn list_for_page(
) -> AppResult<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as(
r#"
SELECT tag
FROM page_tags
WHERE user_id = $1 AND page_id = $2
ORDER BY created_at ASC, tag
SELECT t.name AS tag
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.user_id = $1 AND pt.page_id = $2
ORDER BY pt.created_at ASC, t.name
"#,
)
.bind(user_id)
@@ -143,7 +161,7 @@ pub async fn list_for_user(
let rows = sqlx::query_as::<_, TaggedPageItem>(
r#"
SELECT
pt.tag AS tag,
t.name AS tag,
p.id AS page_id,
p.chapter_id AS chapter_id,
ch.manga_id AS manga_id,
@@ -154,12 +172,13 @@ pub async fn list_for_user(
p.storage_key AS storage_key,
pt.created_at AS tagged_at
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
WHERE pt.user_id = $1
AND ($2::text IS NULL OR pt.tag = $2)
AND ($3::text IS NULL OR pt.tag LIKE $3 || '%' ESCAPE '\')
AND ($2::text IS NULL OR lower(t.name) = $2)
AND ($3::text IS NULL OR lower(t.name) LIKE $3 || '%' ESCAPE '\')
ORDER BY pt.created_at DESC, pt.id
LIMIT $4 OFFSET $5
"#,
@@ -176,10 +195,11 @@ pub async fn list_for_user(
let (total,): (i64,) = sqlx::query_as(
r#"
SELECT count(*)
FROM page_tags
WHERE user_id = $1
AND ($2::text IS NULL OR tag = $2)
AND ($3::text IS NULL OR tag LIKE $3 || '%' ESCAPE '\')
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.user_id = $1
AND ($2::text IS NULL OR lower(t.name) = $2)
AND ($3::text IS NULL OR lower(t.name) LIKE $3 || '%' ESCAPE '\')
"#,
)
.bind(user_id)
@@ -204,12 +224,13 @@ pub async fn distinct_tags_for_user(
let escaped_prefix = prefix.map(escape_like_prefix);
let rows = sqlx::query_as::<_, PageTagSummary>(
r#"
SELECT tag, count(*) AS count
FROM page_tags
WHERE user_id = $1
AND ($2::text IS NULL OR tag LIKE $2 || '%' ESCAPE '\')
GROUP BY tag
ORDER BY count DESC, tag
SELECT t.name AS tag, count(*) AS count
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
WHERE pt.user_id = $1
AND ($2::text IS NULL OR lower(t.name) LIKE $2 || '%' ESCAPE '\')
GROUP BY t.name
ORDER BY count DESC, t.name
LIMIT $3
"#,
)
@@ -252,9 +273,10 @@ pub async fn aggregate_chapters_for_tag(
SELECT p.storage_key, p.page_number
FROM pages p
JOIN page_tags pt2 ON pt2.page_id = p.id
JOIN tags t2 ON t2.id = pt2.tag_id
WHERE p.chapter_id = ch.id
AND pt2.user_id = $1
AND pt2.tag = $2
AND lower(t2.name) = $2
ORDER BY p.page_number ASC
LIMIT 3
) p2
@@ -262,11 +284,12 @@ pub async fn aggregate_chapters_for_tag(
ARRAY[]::text[]
) AS sample_storage_keys
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
WHERE pt.user_id = $1
AND pt.tag = $2
AND lower(t.name) = $2
GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title
ORDER BY match_count {dir}, ch.id
LIMIT $3 OFFSET $4
@@ -286,8 +309,9 @@ pub async fn aggregate_chapters_for_tag(
SELECT count(*) FROM (
SELECT 1
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
JOIN pages p ON p.id = pt.page_id
WHERE pt.user_id = $1 AND pt.tag = $2
WHERE pt.user_id = $1 AND lower(t.name) = $2
GROUP BY p.chapter_id
) c
"#,
@@ -324,9 +348,10 @@ pub async fn aggregate_mangas_for_tag(
FROM pages p
JOIN chapters ch2 ON ch2.id = p.chapter_id
JOIN page_tags pt2 ON pt2.page_id = p.id
JOIN tags t2 ON t2.id = pt2.tag_id
WHERE ch2.manga_id = m.id
AND pt2.user_id = $1
AND pt2.tag = $2
AND lower(t2.name) = $2
ORDER BY p.page_number ASC
LIMIT 3
) p2
@@ -334,11 +359,12 @@ pub async fn aggregate_mangas_for_tag(
ARRAY[]::text[]
) AS sample_storage_keys
FROM page_tags pt
JOIN tags t ON t.id = pt.tag_id
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
JOIN mangas m ON m.id = ch.manga_id
WHERE pt.user_id = $1
AND pt.tag = $2
AND lower(t.name) = $2
GROUP BY m.id, m.title, m.cover_image_path
ORDER BY match_count {dir}, m.id
LIMIT $3 OFFSET $4
@@ -358,9 +384,10 @@ pub async fn aggregate_mangas_for_tag(
SELECT count(*) FROM (
SELECT 1
FROM page_tags pt
JOIN pages p ON p.id = pt.page_id
JOIN tags t ON t.id = pt.tag_id
JOIN pages p ON p.id = pt.page_id
JOIN chapters ch ON ch.id = p.chapter_id
WHERE pt.user_id = $1 AND pt.tag = $2
WHERE pt.user_id = $1 AND lower(t.name) = $2
GROUP BY ch.manga_id
) m
"#,