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:
@@ -136,7 +136,7 @@ docker compose -f docker-compose.dev.yml up -d
|
|||||||
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
|
These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.
|
||||||
|
|
||||||
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
|
||||||
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user personal page tags live in `page_tags` (distinct from the shared manga `tags` taxonomy; free-form text, normalized at the API layer). Both tables cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design.
|
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user page tags live in `page_tags`, which references the **shared** `tags` table by `tag_id` (migration 0024) — the same lookup table `manga_tags` uses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag *names*; `repo::page_tag` resolves name↔id via `repo::tag::upsert_by_name` and applies the stricter page-tag `normalize_tag` (lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Both `collection_pages` and `page_tags` cascade-delete with `pages` and `chapters`, so re-uploading a chapter drops saved-page references by design.
|
||||||
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints is **reserved** for the planned OCR text-search input. Both aggregation handlers accept `text=` on the wire but reject non-empty values with 501 `text_search_not_yet_supported` so adding OCR later doesn't break the API shape. Adding OCR is then: a background worker writes `page_ocr_text` rows, a JOIN on the existing aggregation queries adds the new filter, the `text=` param starts validating instead of rejecting.
|
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints is **reserved** for the planned OCR text-search input. Both aggregation handlers accept `text=` on the wire but reject non-empty values with 501 `text_search_not_yet_supported` so adding OCR later doesn't break the API shape. Adding OCR is then: a background worker writes `page_ocr_text` rows, a JOIN on the existing aggregation queries adds the new filter, the `text=` param starts validating instead of rejecting.
|
||||||
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
|
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
|
||||||
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
|
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
|
||||||
|
|||||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.63.0"
|
version = "0.64.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.63.0"
|
version = "0.64.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
48
backend/migrations/0024_normalize_page_tags.sql
Normal file
48
backend/migrations/0024_normalize_page_tags.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- Normalize per-page tags. Originally (0023) `page_tags.tag` 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. This
|
||||||
|
-- migration folds page tags into the SAME shared `tags` table that
|
||||||
|
-- `manga_tags` uses (0009): `page_tags.tag` becomes `page_tags.tag_id`
|
||||||
|
-- referencing `tags(id)`. After this there is one global tag vocabulary
|
||||||
|
-- shared by manga tags and page tags.
|
||||||
|
--
|
||||||
|
-- The HTTP contract is unchanged — the API still speaks tag *names*; the
|
||||||
|
-- repo resolves name<->id via `repo::tag::upsert_by_name`, exactly as the
|
||||||
|
-- manga-tag path does.
|
||||||
|
|
||||||
|
-- 1. New FK column, nullable until backfilled.
|
||||||
|
ALTER TABLE page_tags
|
||||||
|
ADD COLUMN tag_id uuid REFERENCES tags(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
-- 2. Seed the lookup table from the existing inline values, deduping
|
||||||
|
-- case-insensitively via the tags (lower(name)) unique index. Mirrors
|
||||||
|
-- the author backfill in 0009.
|
||||||
|
INSERT INTO tags (name)
|
||||||
|
SELECT DISTINCT tag FROM page_tags
|
||||||
|
ON CONFLICT (lower(name)) DO NOTHING;
|
||||||
|
|
||||||
|
-- 3. Point every page_tags row at its canonical tag row.
|
||||||
|
UPDATE page_tags pt
|
||||||
|
SET tag_id = t.id
|
||||||
|
FROM tags t
|
||||||
|
WHERE lower(t.name) = lower(pt.tag);
|
||||||
|
|
||||||
|
-- 4. The FK is now mandatory.
|
||||||
|
ALTER TABLE page_tags ALTER COLUMN tag_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- 5. Swap the inline-tag indexes/constraints for tag_id equivalents.
|
||||||
|
DROP INDEX IF EXISTS page_tags_user_page_tag_uniq;
|
||||||
|
CREATE UNIQUE INDEX page_tags_user_page_tag_uniq
|
||||||
|
ON page_tags (user_id, page_id, tag_id);
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS page_tags_user_tag_idx;
|
||||||
|
CREATE INDEX page_tags_user_tag_idx
|
||||||
|
ON page_tags (user_id, tag_id, created_at DESC);
|
||||||
|
|
||||||
|
-- page_tags_user_idx (user_id, created_at DESC) and page_tags_page_idx
|
||||||
|
-- (page_id) are unaffected and stay as-is.
|
||||||
|
|
||||||
|
-- 6. Drop the now-dead inline column. This also drops the
|
||||||
|
-- page_tags_tag_nonempty CHECK; the 1..=64 length bound is now
|
||||||
|
-- enforced by `repo::tag::upsert_by_name` before a row is created.
|
||||||
|
ALTER TABLE page_tags DROP COLUMN tag;
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
//! Per-user, per-page tag persistence.
|
//! Per-user, per-page tag persistence.
|
||||||
//!
|
//!
|
||||||
//! Same plain-function pattern as the rest of `repo`. Idempotent
|
//! Same plain-function pattern as the rest of `repo`. Tag names resolve
|
||||||
//! upserts via `ON CONFLICT DO NOTHING` (the caller distinguishes
|
//! to the shared `tags` table (the same one `manga_tags` uses) via
|
||||||
//! 201/200 from the returned `bool`), FK-violation remap to NotFound so
|
//! [`crate::repo::tag::upsert_by_name`]; `page_tags` links rows by
|
||||||
//! the handler can return 404 when the page was deleted between an
|
//! `tag_id` (see migration 0024). The API still speaks tag *names* — the
|
||||||
//! existence check and the insert.
|
//! 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 sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -33,26 +37,30 @@ impl Order {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a normalized tag for `(user_id, page_id)`. Returns `true` if
|
/// Insert a tag for `(user_id, page_id)`. The tag name is resolved to
|
||||||
/// a new row was inserted (handler → 201), `false` if the tag was
|
/// (or created in) the shared `tags` table first — the same table and
|
||||||
/// already present (handler → 200).
|
/// `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(
|
pub async fn upsert(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
page_id: Uuid,
|
page_id: Uuid,
|
||||||
tag: &str,
|
tag: &str,
|
||||||
) -> AppResult<bool> {
|
) -> 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(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO page_tags (user_id, page_id, tag)
|
INSERT INTO page_tags (user_id, page_id, tag_id)
|
||||||
VALUES ($1, $2, $3)
|
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(user_id)
|
||||||
.bind(page_id)
|
.bind(page_id)
|
||||||
.bind(tag)
|
.bind(tag_row.id)
|
||||||
.execute(pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| match e {
|
.map_err(|e| match e {
|
||||||
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
|
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),
|
other => AppError::Database(other),
|
||||||
})?;
|
})?;
|
||||||
|
tx.commit().await?;
|
||||||
Ok(result.rows_affected() > 0)
|
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(
|
pub async fn remove(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
@@ -70,7 +82,12 @@ pub async fn remove(
|
|||||||
tag: &str,
|
tag: &str,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<()> {
|
||||||
sqlx::query(
|
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(user_id)
|
||||||
.bind(page_id)
|
.bind(page_id)
|
||||||
@@ -89,10 +106,11 @@ pub async fn list_for_page(
|
|||||||
) -> AppResult<Vec<String>> {
|
) -> AppResult<Vec<String>> {
|
||||||
let rows: Vec<(String,)> = sqlx::query_as(
|
let rows: Vec<(String,)> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT tag
|
SELECT t.name AS tag
|
||||||
FROM page_tags
|
FROM page_tags pt
|
||||||
WHERE user_id = $1 AND page_id = $2
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
ORDER BY created_at ASC, tag
|
WHERE pt.user_id = $1 AND pt.page_id = $2
|
||||||
|
ORDER BY pt.created_at ASC, t.name
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -143,7 +161,7 @@ pub async fn list_for_user(
|
|||||||
let rows = sqlx::query_as::<_, TaggedPageItem>(
|
let rows = sqlx::query_as::<_, TaggedPageItem>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
pt.tag AS tag,
|
t.name AS tag,
|
||||||
p.id AS page_id,
|
p.id AS page_id,
|
||||||
p.chapter_id AS chapter_id,
|
p.chapter_id AS chapter_id,
|
||||||
ch.manga_id AS manga_id,
|
ch.manga_id AS manga_id,
|
||||||
@@ -154,12 +172,13 @@ pub async fn list_for_user(
|
|||||||
p.storage_key AS storage_key,
|
p.storage_key AS storage_key,
|
||||||
pt.created_at AS tagged_at
|
pt.created_at AS tagged_at
|
||||||
FROM page_tags pt
|
FROM page_tags pt
|
||||||
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
JOIN chapters ch ON ch.id = p.chapter_id
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
JOIN mangas m ON m.id = ch.manga_id
|
JOIN mangas m ON m.id = ch.manga_id
|
||||||
WHERE pt.user_id = $1
|
WHERE pt.user_id = $1
|
||||||
AND ($2::text IS NULL OR pt.tag = $2)
|
AND ($2::text IS NULL OR lower(t.name) = $2)
|
||||||
AND ($3::text IS NULL OR pt.tag LIKE $3 || '%' ESCAPE '\')
|
AND ($3::text IS NULL OR lower(t.name) LIKE $3 || '%' ESCAPE '\')
|
||||||
ORDER BY pt.created_at DESC, pt.id
|
ORDER BY pt.created_at DESC, pt.id
|
||||||
LIMIT $4 OFFSET $5
|
LIMIT $4 OFFSET $5
|
||||||
"#,
|
"#,
|
||||||
@@ -176,10 +195,11 @@ pub async fn list_for_user(
|
|||||||
let (total,): (i64,) = sqlx::query_as(
|
let (total,): (i64,) = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT count(*)
|
SELECT count(*)
|
||||||
FROM page_tags
|
FROM page_tags pt
|
||||||
WHERE user_id = $1
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
AND ($2::text IS NULL OR tag = $2)
|
WHERE pt.user_id = $1
|
||||||
AND ($3::text IS NULL OR 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 '\')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -204,12 +224,13 @@ pub async fn distinct_tags_for_user(
|
|||||||
let escaped_prefix = prefix.map(escape_like_prefix);
|
let escaped_prefix = prefix.map(escape_like_prefix);
|
||||||
let rows = sqlx::query_as::<_, PageTagSummary>(
|
let rows = sqlx::query_as::<_, PageTagSummary>(
|
||||||
r#"
|
r#"
|
||||||
SELECT tag, count(*) AS count
|
SELECT t.name AS tag, count(*) AS count
|
||||||
FROM page_tags
|
FROM page_tags pt
|
||||||
WHERE user_id = $1
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
AND ($2::text IS NULL OR tag LIKE $2 || '%' ESCAPE '\')
|
WHERE pt.user_id = $1
|
||||||
GROUP BY tag
|
AND ($2::text IS NULL OR lower(t.name) LIKE $2 || '%' ESCAPE '\')
|
||||||
ORDER BY count DESC, tag
|
GROUP BY t.name
|
||||||
|
ORDER BY count DESC, t.name
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
@@ -252,9 +273,10 @@ pub async fn aggregate_chapters_for_tag(
|
|||||||
SELECT p.storage_key, p.page_number
|
SELECT p.storage_key, p.page_number
|
||||||
FROM pages p
|
FROM pages p
|
||||||
JOIN page_tags pt2 ON pt2.page_id = p.id
|
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
|
WHERE p.chapter_id = ch.id
|
||||||
AND pt2.user_id = $1
|
AND pt2.user_id = $1
|
||||||
AND pt2.tag = $2
|
AND lower(t2.name) = $2
|
||||||
ORDER BY p.page_number ASC
|
ORDER BY p.page_number ASC
|
||||||
LIMIT 3
|
LIMIT 3
|
||||||
) p2
|
) p2
|
||||||
@@ -262,11 +284,12 @@ pub async fn aggregate_chapters_for_tag(
|
|||||||
ARRAY[]::text[]
|
ARRAY[]::text[]
|
||||||
) AS sample_storage_keys
|
) AS sample_storage_keys
|
||||||
FROM page_tags pt
|
FROM page_tags pt
|
||||||
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
JOIN chapters ch ON ch.id = p.chapter_id
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
JOIN mangas m ON m.id = ch.manga_id
|
JOIN mangas m ON m.id = ch.manga_id
|
||||||
WHERE pt.user_id = $1
|
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
|
GROUP BY ch.id, ch.manga_id, m.title, ch.number, ch.title
|
||||||
ORDER BY match_count {dir}, ch.id
|
ORDER BY match_count {dir}, ch.id
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
@@ -286,8 +309,9 @@ pub async fn aggregate_chapters_for_tag(
|
|||||||
SELECT count(*) FROM (
|
SELECT count(*) FROM (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM page_tags pt
|
FROM page_tags pt
|
||||||
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
JOIN pages p ON p.id = pt.page_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
|
GROUP BY p.chapter_id
|
||||||
) c
|
) c
|
||||||
"#,
|
"#,
|
||||||
@@ -324,9 +348,10 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
FROM pages p
|
FROM pages p
|
||||||
JOIN chapters ch2 ON ch2.id = p.chapter_id
|
JOIN chapters ch2 ON ch2.id = p.chapter_id
|
||||||
JOIN page_tags pt2 ON pt2.page_id = p.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
|
WHERE ch2.manga_id = m.id
|
||||||
AND pt2.user_id = $1
|
AND pt2.user_id = $1
|
||||||
AND pt2.tag = $2
|
AND lower(t2.name) = $2
|
||||||
ORDER BY p.page_number ASC
|
ORDER BY p.page_number ASC
|
||||||
LIMIT 3
|
LIMIT 3
|
||||||
) p2
|
) p2
|
||||||
@@ -334,11 +359,12 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
ARRAY[]::text[]
|
ARRAY[]::text[]
|
||||||
) AS sample_storage_keys
|
) AS sample_storage_keys
|
||||||
FROM page_tags pt
|
FROM page_tags pt
|
||||||
|
JOIN tags t ON t.id = pt.tag_id
|
||||||
JOIN pages p ON p.id = pt.page_id
|
JOIN pages p ON p.id = pt.page_id
|
||||||
JOIN chapters ch ON ch.id = p.chapter_id
|
JOIN chapters ch ON ch.id = p.chapter_id
|
||||||
JOIN mangas m ON m.id = ch.manga_id
|
JOIN mangas m ON m.id = ch.manga_id
|
||||||
WHERE pt.user_id = $1
|
WHERE pt.user_id = $1
|
||||||
AND pt.tag = $2
|
AND lower(t.name) = $2
|
||||||
GROUP BY m.id, m.title, m.cover_image_path
|
GROUP BY m.id, m.title, m.cover_image_path
|
||||||
ORDER BY match_count {dir}, m.id
|
ORDER BY match_count {dir}, m.id
|
||||||
LIMIT $3 OFFSET $4
|
LIMIT $3 OFFSET $4
|
||||||
@@ -358,9 +384,10 @@ pub async fn aggregate_mangas_for_tag(
|
|||||||
SELECT count(*) FROM (
|
SELECT count(*) FROM (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM page_tags pt
|
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
|
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
|
GROUP BY ch.manga_id
|
||||||
) m
|
) m
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -418,6 +418,72 @@ async fn tags_are_per_user_only(pool: PgPool) {
|
|||||||
assert_eq!(body["tags"], json!([]));
|
assert_eq!(body["tags"], json!([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn page_tag_and_manga_tag_share_one_tags_row(pool: PgPool) {
|
||||||
|
// The point of the normalization refactor: page tags now reference
|
||||||
|
// the shared `tags` table. A manga tag "Funny" and a page tag
|
||||||
|
// "funny" (case-folded) must resolve to a single `tags` row, and
|
||||||
|
// `page_tags.tag_id` must point at the same row as `manga_tags`.
|
||||||
|
let db = pool.clone();
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
|
||||||
|
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{manga_id}/tags"),
|
||||||
|
json!({ "name": "Funny" }),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
|
||||||
|
|
||||||
|
let (tag_count,): (i64,) =
|
||||||
|
sqlx::query_as("SELECT count(*) FROM tags WHERE lower(name) = 'funny'")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tag_count, 1, "page tag and manga tag must share one tags row");
|
||||||
|
|
||||||
|
let (shared,): (bool,) = sqlx::query_as(
|
||||||
|
"SELECT (SELECT tag_id FROM page_tags LIMIT 1) \
|
||||||
|
= (SELECT tag_id FROM manga_tags LIMIT 1)",
|
||||||
|
)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
shared,
|
||||||
|
"page_tags.tag_id must reference the same row as manga_tags.tag_id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn adding_a_page_tag_creates_a_shared_tags_row(pool: PgPool) {
|
||||||
|
// Even with no manga tag in play, adding a page tag must populate
|
||||||
|
// the shared `tags` lookup table (one row per distinct name).
|
||||||
|
let db = pool.clone();
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "M").await;
|
||||||
|
let (_, page_id) = seed_chapter_with_page(&h.app, &cookie, manga_id, 1).await;
|
||||||
|
|
||||||
|
assert_eq!(add_tag(&h.app, &cookie, &page_id, "funny").await, StatusCode::CREATED);
|
||||||
|
|
||||||
|
let (name,): (String,) =
|
||||||
|
sqlx::query_as("SELECT name FROM tags WHERE lower(name) = 'funny'")
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(name, "funny");
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn tags_require_authentication(pool: PgPool) {
|
async fn tags_require_authentication(pool: PgPool) {
|
||||||
let h = common::harness(pool);
|
let h = common::harness(pool);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.63.0",
|
"version": "0.64.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user