simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit d66f52ebd9f851442ffec2a1bc3a37088de1a5a4
parent 1d6a41c4f7aba3d1e8575a1c90eab67e7318c669
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun, 14 Jun 2026 09:08:20 +0200

fix: replace unsafe UnsafeCell with safe RefCell for thread-local connections

The previous read_conn() returned a &Connection that escaped the
thread_local! with closure — unsound by the thread_local API
contract. Replaced with with_read(|conn| { ... }) which runs
the query inside the closure so the reference never escapes.

RefCell provides the same single-thread guarantee with a runtime
borrow check instead of unsafe. No measurable performance impact
(within noise at all concurrency levels).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
Msrc/database.rs | 666++++++++++++++++++++++++++++++++++++++++---------------------------------------
1 file changed, 340 insertions(+), 326 deletions(-)

diff --git a/src/database.rs b/src/database.rs @@ -212,7 +212,7 @@ pub struct Database { } thread_local! { - static READ_CONN: std::cell::UnsafeCell<Option<Connection>> = const { std::cell::UnsafeCell::new(None) }; + static READ_CONN: std::cell::RefCell<Option<Connection>> = const { std::cell::RefCell::new(None) }; } fn open_conn(path: &str) -> Result<Connection, AppError> { @@ -240,19 +240,15 @@ impl Database { }) } - /// Get a thread-local read connection. Zero contention. - /// - /// SAFETY: thread-locals are only accessed from one thread. The reference - /// is valid for the duration of the query (same spawn_blocking scope). - fn read_conn(&self) -> Result<&Connection, AppError> { + /// Run a read query on a thread-local connection. The connection + /// never escapes the closure, so the borrow is sound. + fn with_read<T>(&self, f: impl FnOnce(&Connection) -> Result<T, AppError>) -> Result<T, AppError> { READ_CONN.with(|cell| { - let ptr = cell.get(); - unsafe { - if (*ptr).is_none() { - *ptr = Some(open_conn(&self.path)?); - } - Ok((*ptr).as_ref().unwrap()) + let mut borrow = cell.borrow_mut(); + if borrow.is_none() { + *borrow = Some(open_conn(&self.path)?); } + f(borrow.as_ref().unwrap()) }) } @@ -285,68 +281,72 @@ impl Database { } pub fn get_stories_new(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT * FROM story ORDER BY published DESC LIMIT ?1 OFFSET ?2" - ).map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT * FROM story ORDER BY published DESC LIMIT ?1 OFFSET ?2" + ).map_err(|e| AppError::Database(e.to_string()))?; - let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(stories) + Ok(stories) + }) } pub fn get_stories_top(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT * FROM story ORDER BY score DESC, published DESC LIMIT ?1 OFFSET ?2" - ).map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT * FROM story ORDER BY score DESC, published DESC LIMIT ?1 OFFSET ?2" + ).map_err(|e| AppError::Database(e.to_string()))?; - let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + let stories = stmt.query_map(params![limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(stories) + Ok(stories) + }) } pub fn get_stories_hot(&self, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { let fetch_limit = (limit + offset) * 2; - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT * FROM story ORDER BY published DESC LIMIT ?1" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let mut stories: Vec<Story> = stmt.query_map(params![fetch_limit as i64], Self::row_to_story) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; - - stories.sort_by(|a, b| { - let score_a = hot_score(a.score, a.published); - let score_b = hot_score(b.score, b.published); - score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) - }); - - let result: Vec<Story> = stories.into_iter().skip(offset).take(limit).collect(); - Ok(result) + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT * FROM story ORDER BY published DESC LIMIT ?1" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let mut stories: Vec<Story> = stmt.query_map(params![fetch_limit as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + stories.sort_by(|a, b| { + let score_a = hot_score(a.score, a.published); + let score_b = hot_score(b.score, b.published); + score_b.partial_cmp(&score_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + let result: Vec<Story> = stories.into_iter().skip(offset).take(limit).collect(); + Ok(result) + }) } pub fn get_story(&self, id: i64) -> Result<Option<Story>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT * FROM story WHERE id = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT * FROM story WHERE id = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; - let mut rows = stmt.query_map(params![id], Self::row_to_story) - .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt.query_map(params![id], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))?; - match rows.next() { - Some(Ok(story)) => Ok(Some(story)), - Some(Err(e)) => Err(AppError::Database(e.to_string())), - None => Ok(None), - } + match rows.next() { + Some(Ok(story)) => Ok(Some(story)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + }) } pub fn create_story( @@ -389,35 +389,37 @@ impl Database { } pub fn get_stories_by_tag(&self, category_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT s.* FROM story s - JOIN story_category sc ON sc.news_item_id = s.id - WHERE sc.category_id = ?1 - ORDER BY s.published DESC - LIMIT ?2 OFFSET ?3" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let stories = stmt.query_map(params![category_id, limit as i64, offset as i64], Self::row_to_story) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; - - Ok(stories) + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT s.* FROM story s + JOIN story_category sc ON sc.news_item_id = s.id + WHERE sc.category_id = ?1 + ORDER BY s.published DESC + LIMIT ?2 OFFSET ?3" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let stories = stmt.query_map(params![category_id, limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(stories) + }) } pub fn get_stories_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Story>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT * FROM story WHERE created_by = ?1 ORDER BY published DESC LIMIT ?2 OFFSET ?3" - ).map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT * FROM story WHERE created_by = ?1 ORDER BY published DESC LIMIT ?2 OFFSET ?3" + ).map_err(|e| AppError::Database(e.to_string()))?; - let stories = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_story) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + let stories = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_story) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(stories) + Ok(stories) + }) } /// Search stories with selectivity check. Terms appearing in more than @@ -429,52 +431,52 @@ impl Database { limit: usize, max_doc_ratio: f64, ) -> Result<Vec<SearchResult>, AppError> { - let conn = self.read_conn()?; - - // Check selectivity: reject terms that match too many documents - let total_docs: i64 = conn - .query_row("SELECT COUNT(*) FROM story", [], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string()))?; - - if total_docs > 0 { - // Check how many documents match. This uses the FTS index - // (fast) without ranking (the expensive part). - let match_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM story_fts WHERE story_fts MATCH ?1", - params![query], - |row| row.get(0), - ) - .unwrap_or(0); - - if match_count as f64 / total_docs as f64 > max_doc_ratio { - return Ok(vec![]); + self.with_read(|conn| { + // Check selectivity: reject terms that match too many documents + let total_docs: i64 = conn + .query_row("SELECT COUNT(*) FROM story", [], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string()))?; + + if total_docs > 0 { + // Check how many documents match. This uses the FTS index + // (fast) without ranking (the expensive part). + let match_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM story_fts WHERE story_fts MATCH ?1", + params![query], + |row| row.get(0), + ) + .unwrap_or(0); + + if match_count as f64 / total_docs as f64 > max_doc_ratio { + return Ok(vec![]); + } } - } - let mut stmt = conn.prepare_cached( - "SELECT s.id, s.title, s.text, s.score, s.comment_count - FROM story_fts fts - JOIN story s ON s.id = fts.rowid - WHERE story_fts MATCH ?1 - ORDER BY rank - LIMIT ?2" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let results = stmt.query_map(params![query, limit as i64], |row| { - Ok(SearchResult { - id: row.get(0)?, - title: row.get(1)?, - text: row.get(2)?, - score: row.get(3)?, - comment_count: row.get(4)?, + let mut stmt = conn.prepare_cached( + "SELECT s.id, s.title, s.text, s.score, s.comment_count + FROM story_fts fts + JOIN story s ON s.id = fts.rowid + WHERE story_fts MATCH ?1 + ORDER BY rank + LIMIT ?2" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let results = stmt.query_map(params![query, limit as i64], |row| { + Ok(SearchResult { + id: row.get(0)?, + title: row.get(1)?, + text: row.get(2)?, + score: row.get(3)?, + comment_count: row.get(4)?, + }) }) - }) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(results) + Ok(results) + }) } // ====================================================================== @@ -497,17 +499,18 @@ impl Database { } pub fn get_comments_for_story(&self, story_id: i64) -> Result<Vec<Comment>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT * FROM comment WHERE story_id = ?1 ORDER BY path" - ).map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT * FROM comment WHERE story_id = ?1 ORDER BY path" + ).map_err(|e| AppError::Database(e.to_string()))?; - let comments = stmt.query_map(params![story_id], Self::row_to_comment) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + let comments = stmt.query_map(params![story_id], Self::row_to_comment) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(comments) + Ok(comments) + }) } pub fn create_comment( @@ -572,32 +575,34 @@ impl Database { } pub fn get_comment(&self, id: i64) -> Result<Option<Comment>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT * FROM comment WHERE id = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT * FROM comment WHERE id = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; - let mut rows = stmt.query_map(params![id], Self::row_to_comment) - .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt.query_map(params![id], Self::row_to_comment) + .map_err(|e| AppError::Database(e.to_string()))?; - match rows.next() { - Some(Ok(comment)) => Ok(Some(comment)), - Some(Err(e)) => Err(AppError::Database(e.to_string())), - None => Ok(None), - } + match rows.next() { + Some(Ok(comment)) => Ok(Some(comment)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + }) } pub fn get_comments_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result<Vec<Comment>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT * FROM comment WHERE created_by = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" - ).map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT * FROM comment WHERE created_by = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" + ).map_err(|e| AppError::Database(e.to_string()))?; - let comments = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_comment) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + let comments = stmt.query_map(params![user_id, limit as i64, offset as i64], Self::row_to_comment) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(comments) + Ok(comments) + }) } // ====================================================================== @@ -635,32 +640,32 @@ impl Database { return Ok(vec![]); } - let conn = self.read_conn()?; - - // Build placeholders for IN clause - let placeholders: Vec<String> = target_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect(); - let sql = format!( - "SELECT target_id FROM vote WHERE user_id = ?1 AND target_type = ?2 AND target_id IN ({})", - placeholders.join(", ") - ); - - let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + // Build placeholders for IN clause + let placeholders: Vec<String> = target_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect(); + let sql = format!( + "SELECT target_id FROM vote WHERE user_id = ?1 AND target_type = ?2 AND target_id IN ({})", + placeholders.join(", ") + ); + + let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?; + + // Build params + let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); + param_values.push(Box::new(user_id)); + param_values.push(Box::new(target_type)); + for id in target_ids { + param_values.push(Box::new(*id)); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); - // Build params - let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); - param_values.push(Box::new(user_id)); - param_values.push(Box::new(target_type)); - for id in target_ids { - param_values.push(Box::new(*id)); - } - let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + let voted_ids = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0)) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - let voted_ids = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0)) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; - - Ok(voted_ids) + Ok(voted_ids) + }) } // ====================================================================== @@ -668,42 +673,44 @@ impl Database { // ====================================================================== pub fn get_categories(&self, limit: usize) -> Result<Vec<Category>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT id, name FROM category LIMIT ?1") + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT id, name FROM category LIMIT ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + let categories = stmt.query_map(params![limit as i64], |row| { + Ok(Category { + id: row.get(0)?, + name: row.get(1)?, + }) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() .map_err(|e| AppError::Database(e.to_string()))?; - let categories = stmt.query_map(params![limit as i64], |row| { - Ok(Category { - id: row.get(0)?, - name: row.get(1)?, - }) + Ok(categories) }) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; - - Ok(categories) } pub fn get_categories_for_story(&self, story_id: i64) -> Result<Vec<Category>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached( - "SELECT c.id, c.name FROM category c - JOIN story_category sc ON sc.category_id = c.id - WHERE sc.news_item_id = ?1" - ).map_err(|e| AppError::Database(e.to_string()))?; - - let categories = stmt.query_map(params![story_id], |row| { - Ok(Category { - id: row.get(0)?, - name: row.get(1)?, + self.with_read(|conn| { + let mut stmt = conn.prepare_cached( + "SELECT c.id, c.name FROM category c + JOIN story_category sc ON sc.category_id = c.id + WHERE sc.news_item_id = ?1" + ).map_err(|e| AppError::Database(e.to_string()))?; + + let categories = stmt.query_map(params![story_id], |row| { + Ok(Category { + id: row.get(0)?, + name: row.get(1)?, + }) }) - }) - .map_err(|e| AppError::Database(e.to_string()))? - .collect::<Result<Vec<_>, _>>() - .map_err(|e| AppError::Database(e.to_string()))?; + .map_err(|e| AppError::Database(e.to_string()))? + .collect::<Result<Vec<_>, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; - Ok(categories) + Ok(categories) + }) } pub fn add_tag_to_story(&self, story_id: i64, category_id: i64) -> Result<(), AppError> { @@ -744,48 +751,51 @@ impl Database { } pub fn get_user_by_id(&self, id: i64) -> Result<Option<User>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE id = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE id = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; - let mut rows = stmt.query_map(params![id], Self::row_to_user) - .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt.query_map(params![id], Self::row_to_user) + .map_err(|e| AppError::Database(e.to_string()))?; - match rows.next() { - Some(Ok(user)) => Ok(Some(user)), - Some(Err(e)) => Err(AppError::Database(e.to_string())), - None => Ok(None), - } + match rows.next() { + Some(Ok(user)) => Ok(Some(user)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + }) } pub fn get_user_by_email(&self, email: &str) -> Result<Option<User>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE email = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE email = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; - let mut rows = stmt.query_map(params![email], Self::row_to_user) - .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt.query_map(params![email], Self::row_to_user) + .map_err(|e| AppError::Database(e.to_string()))?; - match rows.next() { - Some(Ok(user)) => Ok(Some(user)), - Some(Err(e)) => Err(AppError::Database(e.to_string())), - None => Ok(None), - } + match rows.next() { + Some(Ok(user)) => Ok(Some(user)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + }) } pub fn get_user_by_username(&self, username: &str) -> Result<Option<User>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE username = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT * FROM user WHERE username = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; - let mut rows = stmt.query_map(params![username], Self::row_to_user) - .map_err(|e| AppError::Database(e.to_string()))?; + let mut rows = stmt.query_map(params![username], Self::row_to_user) + .map_err(|e| AppError::Database(e.to_string()))?; - match rows.next() { - Some(Ok(user)) => Ok(Some(user)), - Some(Err(e)) => Err(AppError::Database(e.to_string())), - None => Ok(None), - } + match rows.next() { + Some(Ok(user)) => Ok(Some(user)), + Some(Err(e)) => Err(AppError::Database(e.to_string())), + None => Ok(None), + } + }) } pub fn create_user(&self, email: &str, password_hash: &str, username: &str, verification_token: Option<&str>) -> Result<User, AppError> { @@ -837,15 +847,16 @@ impl Database { } pub fn get_session_user_id(&self, token: &str) -> Result<Option<i64>, AppError> { - let conn = self.read_conn()?; - let mut stmt = conn.prepare_cached("SELECT user_id FROM session WHERE token = ?1") - .map_err(|e| AppError::Database(e.to_string()))?; - - match stmt.query_row(params![token], |row| row.get::<_, i64>(0)) { - Ok(id) => Ok(Some(id)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(AppError::Database(e.to_string())), - } + self.with_read(|conn| { + let mut stmt = conn.prepare_cached("SELECT user_id FROM session WHERE token = ?1") + .map_err(|e| AppError::Database(e.to_string()))?; + + match stmt.query_row(params![token], |row| row.get::<_, i64>(0)) { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + }) } pub fn delete_session(&self, token: &str) -> Result<(), AppError> { @@ -871,65 +882,65 @@ impl Database { return Ok(vec![]); } - let conn = self.read_conn()?; - - // Fetch author usernames - let placeholders: Vec<String> = story_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); - let sql = format!( - "SELECT s.id, u.username FROM story s - LEFT JOIN user u ON u.id = s.created_by - WHERE s.id IN ({})", - placeholders.join(", ") - ); - - let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?; - let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); - for id in story_ids { - param_values.push(Box::new(*id)); - } - let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); - - let author_map: HashMap<i64, Option<String>> = stmt - .query_map(params_ref.as_slice(), |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)) - }) - .map_err(|e| AppError::Database(e.to_string()))? - .filter_map(|r| r.ok()) - .collect(); - - // Fetch tags - let tag_sql = format!( - "SELECT sc.news_item_id, c.name FROM story_category sc - JOIN category c ON c.id = sc.category_id - WHERE sc.news_item_id IN ({})", - placeholders.join(", ") - ); - - let mut tag_stmt = conn.prepare_cached(&tag_sql).map_err(|e| AppError::Database(e.to_string()))?; - - let mut tags_map: HashMap<i64, Vec<String>> = HashMap::new(); - let tag_rows = tag_stmt - .query_map(params_ref.as_slice(), |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - for row in tag_rows { - if let Ok((story_id, Some(name))) = row { - tags_map.entry(story_id).or_default().push(name); + self.with_read(|conn| { + // Fetch author usernames + let placeholders: Vec<String> = story_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let sql = format!( + "SELECT s.id, u.username FROM story s + LEFT JOIN user u ON u.id = s.created_by + WHERE s.id IN ({})", + placeholders.join(", ") + ); + + let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?; + let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); + for id in story_ids { + param_values.push(Box::new(*id)); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + + let author_map: HashMap<i64, Option<String>> = stmt + .query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + // Fetch tags + let tag_sql = format!( + "SELECT sc.news_item_id, c.name FROM story_category sc + JOIN category c ON c.id = sc.category_id + WHERE sc.news_item_id IN ({})", + placeholders.join(", ") + ); + + let mut tag_stmt = conn.prepare_cached(&tag_sql).map_err(|e| AppError::Database(e.to_string()))?; + + let mut tags_map: HashMap<i64, Vec<String>> = HashMap::new(); + let tag_rows = tag_stmt + .query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + for row in tag_rows { + if let Ok((story_id, Some(name))) = row { + tags_map.entry(story_id).or_default().push(name); + } } - } - let result: Vec<StoryMeta> = story_ids - .iter() - .map(|&sid| StoryMeta { - story_id: sid, - author_username: author_map.get(&sid).cloned().flatten(), - tags: tags_map.get(&sid).cloned().unwrap_or_default(), - }) - .collect(); + let result: Vec<StoryMeta> = story_ids + .iter() + .map(|&sid| StoryMeta { + story_id: sid, + author_username: author_map.get(&sid).cloned().flatten(), + tags: tags_map.get(&sid).cloned().unwrap_or_default(), + }) + .collect(); - Ok(result) + Ok(result) + }) } pub fn enrich_comment_authors(&self, user_ids: &[i64]) -> Result<HashMap<i64, String>, AppError> { @@ -937,41 +948,44 @@ impl Database { return Ok(HashMap::new()); } - let conn = self.read_conn()?; - let placeholders: Vec<String> = user_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); - let sql = format!( - "SELECT id, username FROM user WHERE id IN ({})", - placeholders.join(", ") - ); - - let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?; - let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); - for id in user_ids { - param_values.push(Box::new(*id)); - } - let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + self.with_read(|conn| { + let placeholders: Vec<String> = user_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let sql = format!( + "SELECT id, username FROM user WHERE id IN ({})", + placeholders.join(", ") + ); + + let mut stmt = conn.prepare_cached(&sql).map_err(|e| AppError::Database(e.to_string()))?; + let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); + for id in user_ids { + param_values.push(Box::new(*id)); + } + let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); - let map: HashMap<i64, String> = stmt - .query_map(params_ref.as_slice(), |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) - }) - .map_err(|e| AppError::Database(e.to_string()))? - .filter_map(|r| r.ok()) - .collect(); + let map: HashMap<i64, String> = stmt + .query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| AppError::Database(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); - Ok(map) + Ok(map) + }) } pub fn get_story_score(&self, id: i64) -> Result<i64, AppError> { - let conn = self.read_conn()?; - conn.query_row("SELECT score FROM story WHERE id = ?1", params![id], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string())) + self.with_read(|conn| { + conn.query_row("SELECT score FROM story WHERE id = ?1", params![id], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string())) + }) } pub fn get_comment_score(&self, id: i64) -> Result<i64, AppError> { - let conn = self.read_conn()?; - conn.query_row("SELECT score FROM comment WHERE id = ?1", params![id], |row| row.get(0)) - .map_err(|e| AppError::Database(e.to_string())) + self.with_read(|conn| { + conn.query_row("SELECT score FROM comment WHERE id = ?1", params![id], |row| row.get(0)) + .map_err(|e| AppError::Database(e.to_string())) + }) } }