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:
@@ -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
|
||||
"#,
|
||||
|
||||
Reference in New Issue
Block a user