//! Native SQLite repository implementation using `tokio-rusqlite`. //! //! [`NativeRepository`] wraps a [`tokio_rusqlite::Connection`] and implements //! the [`super::QuoteRepository`] trait for all CRUD operations. It is used for //! local development and testing; production uses `D1Repository` (wasm32). use super::{DbError, DeleteResult, ListResult, QuoteRepository}; use quotesdb::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput}; use rusqlite::OptionalExtension; use tokio_rusqlite::Connection; /// Native SQLite repository backed by `tokio-rusqlite`. /// /// Wraps a `tokio_rusqlite::Connection` and provides async implementations /// of all [`QuoteRepository`] methods. Each method enters the rusqlite /// thread pool via [`Connection::call`]. pub struct NativeRepository { conn: Connection, } impl NativeRepository { /// Create a new [`NativeRepository`] wrapping the given connection. pub fn new(conn: Connection) -> Self { Self { conn } } } /// Fetch the tags for a single quote ID. /// /// Returns a sorted `Vec` of tag values, or an empty vec if none exist. fn fetch_tags_for_quote( conn: &rusqlite::Connection, quote_id: &str, ) -> Result, rusqlite::Error> { let mut stmt = conn.prepare("SELECT tag FROM quote_tags WHERE quote_id = ? ORDER BY tag")?; let tags = stmt .query_map([quote_id], |row| row.get::<_, String>(0))? .collect::, _>>()?; Ok(tags) } /// Map rusqlite columns (id, text, author, source, date, created_at, updated_at) /// plus a pre-fetched tags vec into a [`Quote`]. fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec) -> Result { Ok(Quote { id: row.get(0)?, text: row.get(1)?, author: row.get(2)?, source: row.get(3)?, date: row.get(4)?, created_at: row.get(5)?, updated_at: row.get(6)?, tags, }) } #[async_trait::async_trait] impl QuoteRepository for NativeRepository { /// Run the five DDL migration statements from [`super::migrations`]. /// /// Covers `quotes`, `quote_tags`, tag index, author index, and /// `admin_config`. Safe to call multiple times — all statements use /// `IF NOT EXISTS`. async fn run_migrations(&self) -> Result<(), DbError> { self.conn .call(|conn| { use super::migrations::*; conn.execute_batch(&format!( "{CREATE_QUOTES}; {CREATE_QUOTE_TAGS}; \ {CREATE_TAG_INDEX}; {CREATE_AUTHOR_INDEX}; {CREATE_ADMIN_CONFIG};" ))?; Ok(()) }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// List quotes with optional author/tag/date filters and 1-based pagination. /// /// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering. /// `date_after` and `date_before` are ISO date prefix strings compared via /// `>=` / `<=` against the stored `date` column; rows where `date IS NULL` /// are excluded when either bound is set. /// Tags for each returned quote are fetched in a second query per quote to /// avoid duplicate rows from a JOIN. async fn list_quotes( &self, page: u32, author: Option<&str>, tag: Option<&str>, date_after: Option<&str>, date_before: Option<&str>, ) -> Result { let page = page.max(1); let author = author.map(|s| s.to_owned()); let tag = tag.map(|s| s.to_owned()); let date_after = date_after.map(|s| s.to_owned()); let date_before = date_before.map(|s| s.to_owned()); self.conn .call(move |conn| { const PAGE_SIZE: i64 = 10; // ── Build WHERE clause ──────────────────────────────────── let mut conditions: Vec = Vec::new(); if author.is_some() { conditions.push("q.author = ? COLLATE NOCASE".to_owned()); } if tag.is_some() { conditions .push("q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?)".to_owned()); } // Exclude NULL dates when any date bound is active if date_after.is_some() || date_before.is_some() { conditions.push("q.date IS NOT NULL".to_owned()); } if date_after.is_some() { conditions.push("q.date >= ?".to_owned()); } if date_before.is_some() { conditions.push("q.date <= ?".to_owned()); } let where_clause = if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) }; // Collect bound params in order for both queries let mut params: Vec> = Vec::new(); if let Some(ref a) = author { params.push(Box::new(a.clone())); } if let Some(ref t) = tag { params.push(Box::new(t.clone())); } if let Some(ref da) = date_after { params.push(Box::new(da.clone())); } if let Some(ref db) = date_before { params.push(Box::new(db.clone())); } // ── Count total matching rows ────────────────────────────── let count_sql = format!("SELECT COUNT(*) FROM quotes q {where_clause}"); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|b| b.as_ref()).collect(); let total_count: u32 = conn.query_row( &count_sql, rusqlite::params_from_iter(param_refs.iter().copied()), |row| row.get(0), )?; let total_pages = (((total_count as i64) + PAGE_SIZE - 1) / PAGE_SIZE).max(1) as u32; let offset = ((page as i64) - 1) * PAGE_SIZE; // ── Fetch the page of quotes ────────────────────────────── let list_sql = format!( "SELECT q.id, q.text, q.author, q.source, q.date, \ q.created_at, q.updated_at \ FROM quotes q {where_clause} \ ORDER BY q.created_at DESC \ LIMIT ? OFFSET ?" ); // Re-collect bound params (limit/offset appended at end) let mut params2: Vec> = Vec::new(); if let Some(ref a) = author { params2.push(Box::new(a.clone())); } if let Some(ref t) = tag { params2.push(Box::new(t.clone())); } if let Some(ref da) = date_after { params2.push(Box::new(da.clone())); } if let Some(ref db) = date_before { params2.push(Box::new(db.clone())); } params2.push(Box::new(PAGE_SIZE)); params2.push(Box::new(offset)); let param_refs2: Vec<&dyn rusqlite::types::ToSql> = params2.iter().map(|b| b.as_ref()).collect(); let mut stmt = conn.prepare(&list_sql)?; let partial_quotes: Vec = stmt .query_map( rusqlite::params_from_iter(param_refs2.iter().copied()), |row| { Ok(Quote { id: row.get(0)?, text: row.get(1)?, author: row.get(2)?, source: row.get(3)?, date: row.get(4)?, created_at: row.get(5)?, updated_at: row.get(6)?, tags: vec![], }) }, )? .collect::, _>>()?; // Second pass: fetch tags for each quote let quotes = partial_quotes .into_iter() .map(|mut q| { q.tags = fetch_tags_for_quote(conn, &q.id)?; Ok(q) }) .collect::, rusqlite::Error>>()?; Ok(ListResult { quotes, page, total_pages, total_count, }) }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Retrieve a single quote by its primary key. /// /// Returns `Ok(None)` when no row matches `id`. async fn get_quote(&self, id: &str) -> Result, DbError> { let id = id.to_owned(); self.conn .call(move |conn| { let mut stmt = conn.prepare( "SELECT id, text, author, source, date, created_at, updated_at \ FROM quotes WHERE id = ?", )?; let mut rows = stmt.query([&id as &str])?; match rows.next()? { Some(row) => { let tags = fetch_tags_for_quote(conn, &id)?; Ok(Some(row_to_quote(row, tags)?)) } None => Ok(None), } }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Return one quote chosen at random. /// /// Returns `Ok(None)` when the `quotes` table is empty. async fn get_random_quote(&self) -> Result, DbError> { self.conn .call(|conn| { let mut stmt = conn.prepare( "SELECT id, text, author, source, date, created_at, updated_at \ FROM quotes ORDER BY RANDOM() LIMIT 1", )?; let mut rows = stmt.query([])?; match rows.next()? { Some(row) => { let id: String = row.get(0)?; let tags = fetch_tags_for_quote(conn, &id)?; Ok(Some(row_to_quote(row, tags)?)) } None => Ok(None), } }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Insert a new quote row and its associated tags. /// /// If `input.auth_code` is `None`, a 4-word passphrase is generated. /// Returns the persisted [`Quote`] (without `auth_code`) and the raw /// auth-code string so the caller can include it in the creation response. async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> { let id = generate_id(); let auth_code = input.auth_code.clone().unwrap_or_else(generate_auth_code); let id2 = id.clone(); let auth2 = auth_code.clone(); self.conn .call(move |conn| { conn.execute( "INSERT INTO quotes (id, text, author, source, date, auth_code) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ id2, input.text, input.author, input.source, input.date, auth2, ], )?; for tag in &input.tags { conn.execute( "INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)", rusqlite::params![id2, tag], )?; } // Read back the inserted row to obtain server-generated timestamps let mut stmt = conn.prepare( "SELECT id, text, author, source, date, created_at, updated_at \ FROM quotes WHERE id = ?", )?; let mut rows = stmt.query([&id2 as &str])?; let row = rows.next()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; let tags = fetch_tags_for_quote(conn, &id2)?; let quote = row_to_quote(row, tags)?; Ok((quote, auth2)) }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Update non-`None` fields on an existing quote. /// /// Verifies `auth_code` before making any changes. If `input.tags` is /// `Some`, the entire tag set is replaced. Updates `updated_at` to the /// current UTC time. async fn update_quote( &self, id: &str, input: UpdateQuoteInput, auth_code: &str, ) -> Result { let id = id.to_owned(); let auth_code = auth_code.to_owned(); // Phase 1: fetch stored auth_code (returns DbError on failure) let stored: Option = self .conn .call({ let id = id.clone(); move |conn| { let result: Option = conn .query_row( "SELECT auth_code FROM quotes WHERE id = ?", [&id as &str], |row| row.get(0), ) .optional()?; Ok(result) } }) .await .map_err(|e| DbError::Internal(e.to_string()))?; match stored { None => return Err(DbError::NotFound), Some(ref s) if s.as_str() == auth_code.as_str() => {} // exact match, proceed Some(_) => { // Check admin code fallback let admin = self.get_admin_auth_code().await?; if admin.as_deref() != Some(auth_code.as_str()) { return Err(DbError::Forbidden); } } } // Phase 2: apply the update self.conn .call(move |conn| { let mut sets: Vec = Vec::new(); if input.text.is_some() { sets.push("text = ?".to_owned()); } if input.author.is_some() { sets.push("author = ?".to_owned()); } sets.push("source = ?".to_owned()); sets.push("date = ?".to_owned()); sets.push("updated_at = datetime('now')".to_owned()); let sql = format!("UPDATE quotes SET {} WHERE id = ?", sets.join(", ")); // Build the params vector in the same order as the SET clause let mut params: Vec> = Vec::new(); if let Some(ref text) = input.text { params.push(Box::new(text.clone())); } if let Some(ref author) = input.author { params.push(Box::new(author.clone())); } // source and date may be null (None clears the field) params.push(Box::new(input.source.clone())); params.push(Box::new(input.date.clone())); params.push(Box::new(id.clone())); let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|b| b.as_ref()).collect(); conn.execute(&sql, rusqlite::params_from_iter(param_refs.iter().copied()))?; // Replace tags if provided if let Some(ref tags) = input.tags { conn.execute("DELETE FROM quote_tags WHERE quote_id = ?", [&id as &str])?; for tag in tags { conn.execute( "INSERT OR IGNORE INTO quote_tags (quote_id, tag) \ VALUES (?1, ?2)", rusqlite::params![id, tag], )?; } } // Read back the updated quote let mut stmt = conn.prepare( "SELECT id, text, author, source, date, created_at, updated_at \ FROM quotes WHERE id = ?", )?; let mut rows = stmt.query([&id as &str])?; let row = rows.next()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; let tags = fetch_tags_for_quote(conn, &id)?; Ok(row_to_quote(row, tags)?) }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Delete a quote by ID after verifying the auth code. /// /// Returns [`DeleteResult::NotFound`] if no quote has that ID, /// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the /// admin super auth code matches, or [`DeleteResult::Deleted`] on success. /// Tags are removed automatically by the `ON DELETE CASCADE` constraint on /// `quote_tags`. async fn delete_quote(&self, id: &str, auth_code: &str) -> Result { let id = id.to_owned(); let auth_code = auth_code.to_owned(); self.conn .call(move |conn| { let stored: Option = conn .query_row( "SELECT auth_code FROM quotes WHERE id = ?", [&id as &str], |row| row.get(0), ) .optional()?; match stored { None => return Ok(DeleteResult::NotFound), Some(ref s) if s == &auth_code => { conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?; return Ok(DeleteResult::Deleted); } Some(_) => {} } // Check admin code as fallback let admin: Option = conn .query_row( "SELECT value FROM admin_config WHERE key = 'admin_auth_code'", [], |row| row.get(0), ) .optional()?; if admin.as_deref() == Some(auth_code.as_str()) { conn.execute("DELETE FROM quotes WHERE id = ?", [&id as &str])?; Ok(DeleteResult::Deleted) } else { Ok(DeleteResult::Forbidden) } }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Retrieve the admin super auth code from `admin_config`. /// /// Returns `Ok(None)` if the admin code has not been seeded yet. async fn get_admin_auth_code(&self) -> Result, DbError> { self.conn .call(|conn| { let result: Option = conn .query_row( "SELECT value FROM admin_config WHERE key = 'admin_auth_code'", [], |row| row.get(0), ) .optional()?; Ok(result) }) .await .map_err(|e| DbError::Internal(e.to_string())) } /// Insert the admin auth code if not already present. /// /// Uses `INSERT OR IGNORE` so calling it multiple times is safe. async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { let code = code.to_owned(); self.conn .call(move |conn| { conn.execute( "INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)", rusqlite::params![code], )?; Ok(()) }) .await .map_err(|e| DbError::Internal(e.to_string())) } } #[cfg(test)] mod tests { use super::*; /// Open an in-memory SQLite database for testing. async fn in_memory_repo() -> NativeRepository { let conn = Connection::open_in_memory().await.unwrap(); let repo = NativeRepository::new(conn); repo.run_migrations().await.unwrap(); repo } fn make_input(text: &str, author: &str) -> CreateQuoteInput { CreateQuoteInput { text: text.to_owned(), author: author.to_owned(), source: None, date: None, tags: vec![], auth_code: None, cf_turnstile_token: None, } } #[tokio::test] async fn test_create_and_get_quote() { let repo = in_memory_repo().await; let input = CreateQuoteInput { text: "Hello, world!".to_owned(), author: "Test Author".to_owned(), source: None, date: None, tags: vec!["test".to_owned()], auth_code: Some("word-word-word-word".to_owned()), cf_turnstile_token: None, }; let (quote, auth) = repo.create_quote(input).await.unwrap(); assert_eq!(auth, "word-word-word-word"); assert_eq!(quote.text, "Hello, world!"); assert_eq!(quote.tags, vec!["test"]); let fetched = repo.get_quote("e.id).await.unwrap(); assert!(fetched.is_some()); assert_eq!(fetched.unwrap().id, quote.id); } #[tokio::test] async fn test_get_quote_not_found() { let repo = in_memory_repo().await; let result = repo.get_quote("nonexistent").await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn test_list_quotes_pagination() { let repo = in_memory_repo().await; for i in 0..15 { repo.create_quote(make_input(&format!("Quote {i}"), "Author")) .await .unwrap(); } let page1 = repo.list_quotes(1, None, None, None, None).await.unwrap(); assert_eq!(page1.quotes.len(), 10); assert_eq!(page1.total_count, 15); assert_eq!(page1.total_pages, 2); let page2 = repo.list_quotes(2, None, None, None, None).await.unwrap(); assert_eq!(page2.quotes.len(), 5); } #[tokio::test] async fn test_list_quotes_author_filter() { let repo = in_memory_repo().await; for author in ["Alice", "Bob", "alice"] { repo.create_quote(make_input(&format!("Quote by {author}"), author)) .await .unwrap(); } let result = repo .list_quotes(1, Some("alice"), None, None, None) .await .unwrap(); // COLLATE NOCASE should match "Alice" and "alice" assert_eq!(result.total_count, 2); } #[tokio::test] async fn test_list_quotes_tag_filter() { let repo = in_memory_repo().await; repo.create_quote(CreateQuoteInput { text: "Tagged".to_owned(), author: "A".to_owned(), source: None, date: None, tags: vec!["rust".to_owned()], auth_code: None, cf_turnstile_token: None, }) .await .unwrap(); repo.create_quote(make_input("Not tagged", "B")) .await .unwrap(); let result = repo .list_quotes(1, None, Some("rust"), None, None) .await .unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.quotes[0].text, "Tagged"); } #[tokio::test] async fn test_list_quotes_date_filter() { let repo = in_memory_repo().await; // Insert quotes with specific dates and one without a date for (text, date) in &[ ("Old quote", Some("1990-01-01")), ("Mid quote", Some("2000-06-15")), ("New quote", Some("2020-12-31")), ("No date quote", None), ] { repo.create_quote(CreateQuoteInput { text: text.to_string(), author: "Author".to_owned(), source: None, date: date.map(|d| d.to_owned()), tags: vec![], auth_code: None, cf_turnstile_token: None, }) .await .unwrap(); } // date_after only — should match 2000 and 2020 let result = repo .list_quotes(1, None, None, Some("2000"), None) .await .unwrap(); assert_eq!(result.total_count, 2); // date_before only — should match 1990 and 2000 let result = repo .list_quotes(1, None, None, None, Some("2000-12-31")) .await .unwrap(); assert_eq!(result.total_count, 2); // both bounds — should match only 2000 let result = repo .list_quotes(1, None, None, Some("2000"), Some("2010")) .await .unwrap(); assert_eq!(result.total_count, 1); assert_eq!(result.quotes[0].text, "Mid quote"); // No date quotes are excluded when a bound is active let result_all = repo.list_quotes(1, None, None, None, None).await.unwrap(); assert_eq!(result_all.total_count, 4); // includes "No date quote" let result_bounded = repo .list_quotes(1, None, None, Some("1900"), None) .await .unwrap(); assert_eq!(result_bounded.total_count, 3); // "No date quote" excluded } #[tokio::test] async fn test_random_quote_empty() { let repo = in_memory_repo().await; let result = repo.get_random_quote().await.unwrap(); assert!(result.is_none()); } #[tokio::test] async fn test_random_quote_returns_one() { let repo = in_memory_repo().await; repo.create_quote(make_input("Random", "R")).await.unwrap(); let result = repo.get_random_quote().await.unwrap(); assert!(result.is_some()); } #[tokio::test] async fn test_update_quote_success() { let repo = in_memory_repo().await; let (quote, auth) = repo .create_quote(CreateQuoteInput { text: "Original".to_owned(), author: "Author".to_owned(), source: None, date: None, tags: vec!["old".to_owned()], auth_code: None, cf_turnstile_token: None, }) .await .unwrap(); let updated = repo .update_quote( "e.id, UpdateQuoteInput { text: Some("Updated".to_owned()), author: None, source: None, date: None, tags: Some(vec!["new".to_owned()]), }, &auth, ) .await .unwrap(); assert_eq!(updated.text, "Updated"); assert_eq!(updated.tags, vec!["new"]); } #[tokio::test] async fn test_update_quote_wrong_auth() { let repo = in_memory_repo().await; let (quote, _) = repo .create_quote(CreateQuoteInput { text: "Original".to_owned(), author: "Author".to_owned(), source: None, date: None, tags: vec![], auth_code: Some("correct-code-here-xx".to_owned()), cf_turnstile_token: None, }) .await .unwrap(); let result = repo .update_quote("e.id, UpdateQuoteInput::default(), "wrong-auth-code-yy") .await; assert!(matches!(result, Err(DbError::Forbidden))); } #[tokio::test] async fn test_update_quote_not_found() { let repo = in_memory_repo().await; let result = repo .update_quote("nonexistent", UpdateQuoteInput::default(), "any") .await; assert!(matches!(result, Err(DbError::NotFound))); } #[tokio::test] async fn test_delete_quote_success() { let repo = in_memory_repo().await; let (quote, auth) = repo .create_quote(make_input("Delete me", "Author")) .await .unwrap(); let result = repo.delete_quote("e.id, &auth).await.unwrap(); assert_eq!(result, DeleteResult::Deleted); assert!(repo.get_quote("e.id).await.unwrap().is_none()); } #[tokio::test] async fn test_delete_quote_wrong_auth() { let repo = in_memory_repo().await; let (quote, _) = repo .create_quote(make_input("Protected", "Author")) .await .unwrap(); let result = repo.delete_quote("e.id, "wrong-auth").await.unwrap(); assert_eq!(result, DeleteResult::Forbidden); } #[tokio::test] async fn test_delete_quote_not_found() { let repo = in_memory_repo().await; let result = repo.delete_quote("nonexistent", "any").await.unwrap(); assert_eq!(result, DeleteResult::NotFound); } }