//! Author persistence. Same plain-function style as the other repos. //! //! Names are stored with the casing of the first submission; lookups go //! through the case-insensitive unique index on `lower(name)` so //! "Kentaro Miura" and "kentaro miura" share one row. use std::collections::BTreeMap; use sqlx::{PgConnection, PgExecutor, PgPool}; use uuid::Uuid; use crate::domain::author::{Author, AuthorRef, AuthorWithCount}; use crate::domain::manga::Manga; use crate::error::{AppError, AppResult}; /// Insert-or-find by name. Returns the canonical row whether or not /// this call created it. The `ON CONFLICT … DO UPDATE` no-op is what /// makes `RETURNING` come back even on the existing-row path. pub async fn upsert_by_name<'e, E: PgExecutor<'e>>( executor: E, name: &str, ) -> AppResult { let trimmed = name.trim(); if trimmed.is_empty() { return Err(AppError::ValidationFailed { message: "author name cannot be empty".into(), details: serde_json::json!({ "authors": "non-empty names required" }), }); } let row = sqlx::query_as::<_, Author>( r#" INSERT INTO authors (name) VALUES ($1) ON CONFLICT (lower(name)) DO UPDATE SET name = authors.name RETURNING id, name, created_at "#, ) .bind(trimmed) .fetch_one(executor) .await?; Ok(row) } pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult { sqlx::query_as::<_, Author>( "SELECT id, name, created_at FROM authors WHERE id = $1", ) .bind(id) .fetch_optional(pool) .await? .ok_or(AppError::NotFound) } pub async fn find_with_count(pool: &PgPool, id: Uuid) -> AppResult { sqlx::query_as::<_, AuthorWithCount>( r#" SELECT a.id, a.name, a.created_at, (SELECT count(*) FROM manga_authors ma WHERE ma.author_id = a.id) AS manga_count FROM authors a WHERE a.id = $1 "#, ) .bind(id) .fetch_optional(pool) .await? .ok_or(AppError::NotFound) } /// Autocomplete-friendly: substring + trigram match on `name`, ordered /// most-similar first when a search term is present. pub async fn list( pool: &PgPool, search: Option<&str>, limit: i64, offset: i64, ) -> AppResult> { let rows = sqlx::query_as::<_, Author>( r#" SELECT id, name, created_at FROM authors WHERE $1::text IS NULL OR name ILIKE '%' || $1 || '%' OR name % $1 ORDER BY CASE WHEN $1::text IS NULL THEN 0 ELSE similarity(name, $1) END DESC, lower(name) ASC LIMIT $2 OFFSET $3 "#, ) .bind(search) .bind(limit) .bind(offset) .fetch_all(pool) .await?; Ok(rows) } /// Atomically replace the set of authors on a manga. Caller passes a /// `&mut PgConnection` (`&mut *tx` works) so the delete+upserts run in /// one transaction with whatever called us. /// /// Note: `crawler::repo::sync_authors` does a similar replace with the /// same semantics on names. The duplication is intentional — handler /// callers want the `Vec` for the API response; the /// crawler doesn't need it and stays inside its own transaction. pub async fn set_for_manga( conn: &mut PgConnection, manga_id: Uuid, names: &[String], ) -> AppResult> { sqlx::query("DELETE FROM manga_authors WHERE manga_id = $1") .bind(manga_id) .execute(&mut *conn) .await?; let mut refs = Vec::with_capacity(names.len()); for (position, name) in names.iter().enumerate() { let author = upsert_by_name(&mut *conn, name).await?; sqlx::query( "INSERT INTO manga_authors (manga_id, author_id, position) VALUES ($1, $2, $3) ON CONFLICT (manga_id, author_id) DO NOTHING", ) .bind(manga_id) .bind(author.id) .bind(position as i32) .execute(&mut *conn) .await?; refs.push(AuthorRef { id: author.id, name: author.name }); } Ok(refs) } pub async fn list_for_manga(pool: &PgPool, manga_id: Uuid) -> AppResult> { let rows = sqlx::query_as::<_, AuthorRef>( r#" SELECT a.id, a.name FROM manga_authors ma JOIN authors a ON a.id = ma.author_id WHERE ma.manga_id = $1 ORDER BY ma.position, lower(a.name) "#, ) .bind(manga_id) .fetch_all(pool) .await?; Ok(rows) } /// Batch-load authors for a list of manga ids and group by manga. Used /// by the list endpoint to avoid N+1 round-trips. pub async fn load_for_mangas( pool: &PgPool, manga_ids: &[Uuid], ) -> AppResult>> { if manga_ids.is_empty() { return Ok(BTreeMap::new()); } let rows: Vec<(Uuid, Uuid, String)> = sqlx::query_as( r#" SELECT ma.manga_id, a.id, a.name FROM manga_authors ma JOIN authors a ON a.id = ma.author_id WHERE ma.manga_id = ANY($1) ORDER BY ma.manga_id, ma.position, lower(a.name) "#, ) .bind(manga_ids) .fetch_all(pool) .await?; let mut grouped: BTreeMap> = BTreeMap::new(); for (manga_id, author_id, name) in rows { grouped .entry(manga_id) .or_default() .push(AuthorRef { id: author_id, name }); } Ok(grouped) } pub async fn list_mangas_for_author( pool: &PgPool, author_id: Uuid, limit: i64, offset: i64, ) -> AppResult<(Vec, i64)> { let rows = sqlx::query_as::<_, Manga>( r#" SELECT m.id, m.title, m.status, m.alt_titles, m.description, m.cover_image_path, m.created_at, m.updated_at FROM manga_authors ma JOIN mangas m ON m.id = ma.manga_id WHERE ma.author_id = $1 ORDER BY m.created_at DESC, m.id LIMIT $2 OFFSET $3 "#, ) .bind(author_id) .bind(limit) .bind(offset) .fetch_all(pool) .await?; let (total,): (i64,) = sqlx::query_as( "SELECT count(*) FROM manga_authors WHERE author_id = $1", ) .bind(author_id) .fetch_one(pool) .await?; Ok((rows, total)) }