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:
| M | src/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()))
+ })
}
}