diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 986d61f..dd47dbe 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1033,7 +1033,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "mangalord" -version = "0.7.0" +version = "0.8.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7034098..a4a9604 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.7.0" +version = "0.8.0" edition = "2021" [lib] diff --git a/backend/migrations/0005_search.sql b/backend/migrations/0005_search.sql new file mode 100644 index 0000000..ed6257d --- /dev/null +++ b/backend/migrations/0005_search.sql @@ -0,0 +1,15 @@ +-- Trigram-backed fuzzy search on mangas. The `%` operator becomes +-- index-supported once gin_trgm_ops is in place, so the search query +-- can mix substring matches (ILIKE) with fuzzy matches (similarity > +-- pg_trgm.similarity_threshold, default 0.3) without a full scan. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX mangas_title_trgm_idx + ON mangas USING gin (title gin_trgm_ops); + +-- Author is nullable; index only the rows that have one so the index +-- stays tight. +CREATE INDEX mangas_author_trgm_idx + ON mangas USING gin (author gin_trgm_ops) + WHERE author IS NOT NULL; diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index 4aba780..75b580a 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -28,6 +28,8 @@ pub struct ListParams { pub limit: i64, #[serde(default)] pub offset: i64, + #[serde(default)] + pub sort: repo::manga::ListSort, } fn default_limit() -> i64 { @@ -44,9 +46,10 @@ async fn list( search: params.search.filter(|s| !s.trim().is_empty()), limit, offset, + sort: params.sort, }; - let items = repo::manga::list(&state.db, &q).await?; - Ok(Json(PagedResponse::new(items, limit, offset))) + let (items, total) = repo::manga::list(&state.db, &q).await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) } async fn get_one( diff --git a/backend/src/api/pagination.rs b/backend/src/api/pagination.rs index d4ae3de..75aa721 100644 --- a/backend/src/api/pagination.rs +++ b/backend/src/api/pagination.rs @@ -27,4 +27,11 @@ impl PagedResponse { page: PageInfo { limit, offset, total: None }, } } + + pub fn with_total(items: Vec, limit: i64, offset: i64, total: i64) -> Self { + Self { + items, + page: PageInfo { limit, offset, total: Some(total) }, + } + } } diff --git a/backend/src/repo/manga.rs b/backend/src/repo/manga.rs index c82e755..b2f78df 100644 --- a/backend/src/repo/manga.rs +++ b/backend/src/repo/manga.rs @@ -5,44 +5,90 @@ //! 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::PgPool; use uuid::Uuid; use crate::domain::manga::{Manga, NewManga}; use crate::error::{AppError, AppResult}; +#[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)] pub struct ListQuery { pub search: Option, pub limit: i64, pub offset: i64, + pub sort: ListSort, } impl Default for ListQuery { fn default() -> Self { - Self { search: None, limit: 50, offset: 0 } + Self { + search: None, + limit: 50, + offset: 0, + sort: ListSort::Recent, + } } } -pub async fn list(pool: &PgPool, query: &ListQuery) -> AppResult> { - let pattern = query.search.as_deref().map(|s| format!("%{}%", s)); - let rows = sqlx::query_as::<_, Manga>( +/// Returns the page of mangas matching `query` plus the unfiltered total +/// count for the same filter. The trigram GIN indexes (see 0005_search.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 list_sql = format!( r#" SELECT id, title, author, description, cover_image_path, created_at, updated_at FROM mangas WHERE $1::text IS NULL - OR title ILIKE $1 - OR COALESCE(author, '') ILIKE $1 - ORDER BY created_at DESC + OR title ILIKE '%' || $1 || '%' + OR COALESCE(author, '') ILIKE '%' || $1 || '%' + OR title % $1 + OR (author IS NOT NULL AND author % $1) + ORDER BY {order_by} LIMIT $2 OFFSET $3 - "#, - ) - .bind(pattern) - .bind(query.limit) - .bind(query.offset) - .fetch_all(pool) - .await?; - Ok(rows) + "# + ); + + let rows = sqlx::query_as::<_, Manga>(&list_sql) + .bind(search) + .bind(query.limit) + .bind(query.offset) + .fetch_all(pool) + .await?; + + let count_sql = r#" + SELECT count(*) FROM mangas + WHERE $1::text IS NULL + OR title ILIKE '%' || $1 || '%' + OR COALESCE(author, '') ILIKE '%' || $1 || '%' + OR title % $1 + OR (author IS NOT NULL AND author % $1) + "#; + let (total,): (i64,) = sqlx::query_as(count_sql) + .bind(search) + .fetch_one(pool) + .await?; + + Ok((rows, total)) } pub async fn get(pool: &PgPool, id: Uuid) -> AppResult { diff --git a/backend/tests/api_mangas.rs b/backend/tests/api_mangas.rs index 18d6a95..c369c77 100644 --- a/backend/tests/api_mangas.rs +++ b/backend/tests/api_mangas.rs @@ -20,7 +20,130 @@ async fn list_is_empty_initially(pool: PgPool) { assert_eq!(body["items"], json!([])); assert_eq!(body["page"]["limit"], 50); assert_eq!(body["page"]["offset"], 0); - assert!(body["page"]["total"].is_null()); + assert_eq!(body["page"]["total"], 0); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_returns_total_count_independent_of_pagination(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + for title in ["One Piece", "Berserk", "Vinland Saga"] { + let _ = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json("metadata", json!({ "title": title })), + &cookie, + )) + .await + .unwrap(); + } + + let resp = h + .app + .oneshot(common::get("/api/v1/mangas?limit=2")) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["items"].as_array().unwrap().len(), 2); + // Total reflects the unfiltered population, not the page size. + assert_eq!(body["page"]["total"], 3); +} + +#[sqlx::test(migrations = "./migrations")] +async fn search_via_trigram_tolerates_typos(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let _ = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json("metadata", json!({ "title": "Naruto" })), + &cookie, + )) + .await + .unwrap(); + + // 'narto' is one letter off — the % operator on the GIN trgm index + // should still match it. + let resp = h + .app + .oneshot(common::get("/api/v1/mangas?search=narto")) + .await + .unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + assert_eq!(titles, vec!["Naruto"]); + assert_eq!(body["page"]["total"], 1); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_sort_title_orders_alphabetically(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + for title in ["Vinland Saga", "Berserk", "One Piece"] { + let _ = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json("metadata", json!({ "title": title })), + &cookie, + )) + .await + .unwrap(); + } + + let resp = h + .app + .oneshot(common::get("/api/v1/mangas?sort=title")) + .await + .unwrap(); + let body = common::body_json(resp).await; + let titles: Vec<&str> = body["items"] + .as_array() + .unwrap() + .iter() + .map(|m| m["title"].as_str().unwrap()) + .collect(); + assert_eq!(titles, vec!["Berserk", "One Piece", "Vinland Saga"]); +} + +#[sqlx::test(migrations = "./migrations")] +async fn search_reflects_filtered_total(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + for title in ["One Piece", "Berserk", "Vinland Saga"] { + let _ = h + .app + .clone() + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new().add_json("metadata", json!({ "title": title })), + &cookie, + )) + .await + .unwrap(); + } + + let resp = h + .app + .oneshot(common::get("/api/v1/mangas?search=berserk")) + .await + .unwrap(); + let body = common::body_json(resp).await; + assert_eq!(body["items"].as_array().unwrap().len(), 1); + assert_eq!(body["page"]["total"], 1); } #[sqlx::test(migrations = "./migrations")] diff --git a/frontend/package.json b/frontend/package.json index 813c667..e767409 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.7.0", + "version": "0.8.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/mangas.test.ts b/frontend/src/lib/api/mangas.test.ts index 3d7c8b8..567167a 100644 --- a/frontend/src/lib/api/mangas.test.ts +++ b/frontend/src/lib/api/mangas.test.ts @@ -60,14 +60,15 @@ describe('mangas api client', () => { expect(result.page).toEqual({ limit: 50, offset: 0, total: null }); }); - it('listMangas encodes search, limit, offset', async () => { + it('listMangas encodes search, limit, offset, sort', async () => { fetchSpy.mockResolvedValueOnce(ok(emptyPage())); - await listMangas({ search: 'one piece', limit: 10, offset: 20 }); + await listMangas({ search: 'one piece', limit: 10, offset: 20, sort: 'title' }); const url = fetchSpy.mock.calls[0][0] as string; expect(url).toMatch(/\/v1\/mangas\?/); expect(url).toContain('search=one+piece'); expect(url).toContain('limit=10'); expect(url).toContain('offset=20'); + expect(url).toContain('sort=title'); }); it('createManga POSTs multipart with metadata to /v1/mangas', async () => { diff --git a/frontend/src/lib/api/mangas.ts b/frontend/src/lib/api/mangas.ts index 3a5ceb6..5629564 100644 --- a/frontend/src/lib/api/mangas.ts +++ b/frontend/src/lib/api/mangas.ts @@ -1,9 +1,12 @@ import { request, type Manga, type Page } from './client'; +export type MangaSort = 'recent' | 'title'; + export type ListOptions = { search?: string; limit?: number; offset?: number; + sort?: MangaSort; }; export type MangasPage = { @@ -16,6 +19,7 @@ export async function listMangas(opts: ListOptions = {}): Promise { if (opts.search) params.set('search', opts.search); if (opts.limit != null) params.set('limit', String(opts.limit)); if (opts.offset != null) params.set('offset', String(opts.offset)); + if (opts.sort) params.set('sort', opts.sort); const qs = params.toString(); return request(`/v1/mangas${qs ? `?${qs}` : ''}`); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index af66094..5e006b2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,9 +1,11 @@ @@ -30,6 +41,7 @@ load(); }} action="javascript:void(0)" + class="controls" > + @@ -47,6 +66,11 @@ {:else if mangas.length === 0}

No mangas yet. Upload one.

{:else} + {#if total !== null} +

+ Showing {mangas.length} of {total} +

+ {/if}
    {#each mangas as m (m.id)}
  • @@ -56,3 +80,21 @@ {/each}
{/if} + +