//! Manga persistence. //! //! Plain async functions over `&PgPool` rather than a repository struct — //! each function is easy to test in isolation with `#[sqlx::test]`, and //! handlers depend only on `sqlx::PgPool`, not on a trait object. Swap to //! a trait + impl if a second backend ever becomes necessary. use serde::Deserialize; use sqlx::{PgConnection, PgExecutor, PgPool}; use uuid::Uuid; use crate::domain::manga::{Manga, MangaCard, MangaDetail}; use crate::error::{AppError, AppResult}; use crate::repo; /// Status values mirror the CHECK constraint in 0009. Centralized so /// the API layer can validate uploads against the same vocabulary. pub const STATUSES: &[&str] = &["ongoing", "completed"]; pub const DEFAULT_STATUS: &str = "ongoing"; #[derive(Debug, Clone, Copy, Default, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ListSort { /// Newest first (default). #[default] Recent, /// A→Z by title (case-insensitive). Title, } #[derive(Debug, Clone, Default)] pub struct ListQuery { pub search: Option, pub status: Option, pub author_ids: Vec, pub genre_ids: Vec, pub tag_ids: Vec, pub limit: i64, pub offset: i64, pub sort: ListSort, } const SELECT_COLS: &str = "id, title, status, alt_titles, description, cover_image_path, created_at, updated_at"; /// Shared WHERE used by both the rows and the count queries. Filters /// are AND across facets: every supplied author_id (or genre, or tag) /// must be attached to the manga for it to match. Empty arrays mean /// "no filter" because `NOT EXISTS` over an empty unnest is vacuously /// true. const FILTER_WHERE: &str = r#" ($1::text IS NULL OR title ILIKE '%' || $1 || '%' OR title % $1 OR EXISTS ( SELECT 1 FROM manga_authors ma JOIN authors a ON a.id = ma.author_id WHERE ma.manga_id = mangas.id AND (a.name ILIKE '%' || $1 || '%' OR a.name % $1) ) ) AND ($2::text IS NULL OR status = $2) AND NOT EXISTS ( SELECT 1 FROM unnest($3::uuid[]) AS req(id) WHERE NOT EXISTS ( SELECT 1 FROM manga_authors ma WHERE ma.manga_id = mangas.id AND ma.author_id = req.id ) ) AND NOT EXISTS ( SELECT 1 FROM unnest($4::uuid[]) AS req(id) WHERE NOT EXISTS ( SELECT 1 FROM manga_genres mg WHERE mg.manga_id = mangas.id AND mg.genre_id = req.id ) ) AND NOT EXISTS ( SELECT 1 FROM unnest($5::uuid[]) AS req(id) WHERE NOT EXISTS ( SELECT 1 FROM manga_tags mt WHERE mt.manga_id = mangas.id AND mt.tag_id = req.id ) ) "#; /// Returns the page of mangas matching `query` plus the unfiltered total /// count for the same filter. The trigram GIN indexes (see 0005_search.sql /// and 0009_manga_metadata.sql) keep both queries cheap as the library /// grows. pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult<(Vec, i64)> { // `order_by` is interpolated from a hard-coded enum, never from request // input, so this is not a SQL injection seam. let order_by = match query.sort { ListSort::Recent => "created_at DESC, id", ListSort::Title => "lower(title) ASC, id", }; let search = query.search.as_deref(); let status = query.status.as_deref(); let list_sql = format!( r#" SELECT {SELECT_COLS} FROM mangas WHERE {FILTER_WHERE} ORDER BY {order_by} LIMIT $6 OFFSET $7 "# ); let rows = sqlx::query_as::<_, Manga>(&list_sql) .bind(search) .bind(status) .bind(&query.author_ids) .bind(&query.genre_ids) .bind(&query.tag_ids) .bind(query.limit) .bind(query.offset) .fetch_all(pool) .await?; let count_sql = format!( r#" SELECT count(*) FROM mangas WHERE {FILTER_WHERE} "# ); let (total,): (i64,) = sqlx::query_as(&count_sql) .bind(search) .bind(status) .bind(&query.author_ids) .bind(&query.genre_ids) .bind(&query.tag_ids) .fetch_one(pool) .await?; Ok((rows, total)) } /// Same filter as `list`, but wraps each row with its authors + genres /// in a single batched round-trip. Tags are intentionally not loaded /// here — see `MangaCard` in the domain layer. pub async fn list_cards( pool: &PgPool, query: &ListQuery, ) -> AppResult<(Vec, i64)> { let (rows, total) = list(pool, query).await?; let ids: Vec = rows.iter().map(|m| m.id).collect(); let mut authors = repo::author::load_for_mangas(pool, &ids).await?; let mut genres = repo::genre::load_for_mangas(pool, &ids).await?; let cards = rows .into_iter() .map(|manga| MangaCard { authors: authors.remove(&manga.id).unwrap_or_default(), genres: genres.remove(&manga.id).unwrap_or_default(), manga, }) .collect(); Ok((cards, total)) } pub async fn get(pool: &PgPool, id: Uuid) -> AppResult { sqlx::query_as::<_, Manga>(&format!( "SELECT {SELECT_COLS} FROM mangas WHERE id = $1" )) .bind(id) .fetch_optional(pool) .await? .ok_or(AppError::NotFound) } pub async fn get_detail(pool: &PgPool, id: Uuid) -> AppResult { let manga = get(pool, id).await?; let authors = repo::author::list_for_manga(pool, id).await?; let genres = repo::genre::list_for_manga(pool, id).await?; let tags = repo::tag::list_for_manga(pool, id).await?; Ok(MangaDetail { manga, authors, genres, tags }) } /// Insert just the manga row. Relations (authors, genres) are written /// by the caller via `repo::author::set_for_manga` etc. in the same /// transaction. `status` is taken as a validated string — the handler /// is responsible for defaulting/validating it. /// /// `uploaded_by` records who created the manga and feeds the per-user /// upload history. `None` means "historical / no associated user" — /// historic rows from before the uploader columns were added carry /// NULL. pub async fn create<'e, E: PgExecutor<'e>>( executor: E, title: &str, status: &str, description: Option<&str>, alt_titles: &[String], uploaded_by: Option, ) -> AppResult { let row = sqlx::query_as::<_, Manga>(&format!( r#" INSERT INTO mangas (title, status, description, alt_titles, uploaded_by) VALUES ($1, $2, $3, $4, $5) RETURNING {SELECT_COLS} "# )) .bind(title) .bind(status) .bind(description) .bind(alt_titles) .bind(uploaded_by) .fetch_one(executor) .await?; Ok(row) } /// Patch the inline columns. Each `Option` argument leaves the field /// untouched when `None`. `description` uses two binds — a `provided` /// boolean and the new value — so callers can distinguish "leave /// alone" from "set to NULL" (both look the same in a plain /// `Option<&str>` because serde collapses missing and explicit-null). pub async fn update_basics( conn: &mut PgConnection, id: Uuid, title: Option<&str>, status: Option<&str>, description_provided: bool, description: Option<&str>, alt_titles: Option<&[String]>, ) -> AppResult { let row = sqlx::query_as::<_, Manga>(&format!( r#" UPDATE mangas SET title = COALESCE($2, title), status = COALESCE($3, status), description = CASE WHEN $4::boolean THEN $5 ELSE description END, alt_titles = COALESCE($6, alt_titles), updated_at = now() WHERE id = $1 RETURNING {SELECT_COLS} "# )) .bind(id) .bind(title) .bind(status) .bind(description_provided) .bind(description) .bind(alt_titles) .fetch_optional(&mut *conn) .await? .ok_or(AppError::NotFound)?; Ok(row) } pub async fn set_cover_image_path<'e, E: PgExecutor<'e>>( executor: E, id: Uuid, key: &str, ) -> AppResult<()> { sqlx::query("UPDATE mangas SET cover_image_path = $1, updated_at = now() WHERE id = $2") .bind(key) .bind(id) .execute(executor) .await?; Ok(()) } pub async fn clear_cover_image_path<'e, E: PgExecutor<'e>>( executor: E, id: Uuid, ) -> AppResult<()> { sqlx::query("UPDATE mangas SET cover_image_path = NULL, updated_at = now() WHERE id = $1") .bind(id) .execute(executor) .await?; Ok(()) } pub async fn exists(pool: &PgPool, id: Uuid) -> AppResult { let (exists,): (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM mangas WHERE id = $1)") .bind(id) .fetch_one(pool) .await?; Ok(exists) }