Hand video playback off to a native audio-only stream when the app is backgrounded or locked, with no on-device video decode (UR-040). Adds player_enter/exit_background_audio commands, an audio-only stream URL for video items across the repository layer, and the frontend handoff state machine wired into VideoPlayer. Includes accompanying repository/offline/player refactoring and regenerates the traceability matrix.
414 lines
13 KiB
Rust
414 lines
13 KiB
Rust
//! Database service abstraction layer
|
|
//!
|
|
//! This module provides an async database interface that abstracts away
|
|
//! the underlying database implementation. This makes it easy to:
|
|
//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
|
|
//! - Prevent blocking the async runtime with synchronous database calls
|
|
//! - Test with different database backends
|
|
//! - Migrate to other database systems in the future
|
|
|
|
use async_trait::async_trait;
|
|
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
/// Database query result type
|
|
pub type DbResult<T> = Result<T, String>;
|
|
|
|
/// Represents a database query that can be executed
|
|
#[derive(Clone)]
|
|
pub struct Query {
|
|
pub sql: String,
|
|
pub params: Vec<QueryParam>,
|
|
}
|
|
|
|
/// Query parameter types supported by the database
|
|
#[derive(Clone, Debug)]
|
|
pub enum QueryParam {
|
|
String(String),
|
|
Int(i32),
|
|
Int64(i64),
|
|
Float(f64),
|
|
#[allow(dead_code)]
|
|
Bool(bool),
|
|
Null,
|
|
}
|
|
|
|
impl Query {
|
|
pub fn new(sql: impl Into<String>) -> Self {
|
|
Self {
|
|
sql: sql.into(),
|
|
params: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_params(sql: impl Into<String>, params: Vec<QueryParam>) -> Self {
|
|
Self {
|
|
sql: sql.into(),
|
|
params,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Database service trait - abstraction over database operations
|
|
#[async_trait]
|
|
pub trait DatabaseService: Send + Sync {
|
|
/// Execute a query that doesn't return results (INSERT, UPDATE, DELETE)
|
|
async fn execute(&self, query: Query) -> DbResult<usize>;
|
|
|
|
/// Execute a batch of SQL statements (for migrations)
|
|
#[allow(dead_code)]
|
|
async fn execute_batch(&self, sql: &str) -> DbResult<()>;
|
|
|
|
/// Query a single row
|
|
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
|
where
|
|
T: Send + 'static,
|
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
|
|
|
/// Query a single optional row
|
|
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
|
where
|
|
T: Send + 'static,
|
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
|
|
|
/// Query multiple rows
|
|
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
|
where
|
|
T: Send + 'static,
|
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static;
|
|
|
|
/// Run a transaction with multiple operations
|
|
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
|
where
|
|
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
|
T: Send + 'static;
|
|
|
|
/// Get the row ID of the most recent successful INSERT
|
|
async fn last_insert_rowid(&self) -> DbResult<i64>;
|
|
}
|
|
|
|
/// Transaction handle for batching multiple operations
|
|
pub struct Transaction<'a> {
|
|
conn: &'a Connection,
|
|
}
|
|
|
|
impl<'a> Transaction<'a> {
|
|
pub fn new(conn: &'a Connection) -> Self {
|
|
Self { conn }
|
|
}
|
|
|
|
pub fn execute(&mut self, query: Query) -> DbResult<usize> {
|
|
execute_query(self.conn, query)
|
|
}
|
|
|
|
pub fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
|
where
|
|
F: Fn(&Row) -> SqliteResult<T>,
|
|
{
|
|
query_many(self.conn, query, mapper)
|
|
}
|
|
}
|
|
|
|
/// Rusqlite-based database service implementation
|
|
///
|
|
/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
|
|
/// to prevent blocking the async runtime.
|
|
pub struct RusqliteService {
|
|
conn: Arc<Mutex<Connection>>,
|
|
}
|
|
|
|
impl RusqliteService {
|
|
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
|
Self { conn }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DatabaseService for RusqliteService {
|
|
async fn execute(&self, query: Query) -> DbResult<usize> {
|
|
let conn = Arc::clone(&self.conn);
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
execute_query(&conn, query)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
|
|
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
|
|
let conn = Arc::clone(&self.conn);
|
|
let sql = sql.to_string();
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
conn.execute_batch(&sql)
|
|
.map_err(|e| format!("Execute batch failed: {}", e))
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
|
|
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
|
where
|
|
T: Send + 'static,
|
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
|
{
|
|
let conn = Arc::clone(&self.conn);
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
query_one(&conn, query, mapper)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
|
|
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
|
where
|
|
T: Send + 'static,
|
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
|
{
|
|
let conn = Arc::clone(&self.conn);
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
query_optional(&conn, query, mapper)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
|
|
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
|
where
|
|
T: Send + 'static,
|
|
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
|
{
|
|
let conn = Arc::clone(&self.conn);
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
query_many(&conn, query, mapper)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
|
|
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
|
where
|
|
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
|
T: Send + 'static,
|
|
{
|
|
let conn = Arc::clone(&self.conn);
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
|
|
conn.execute("BEGIN TRANSACTION", [])
|
|
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
|
|
|
let mut transaction = Transaction::new(&conn);
|
|
let result = f(&mut transaction);
|
|
|
|
match result {
|
|
Ok(value) => {
|
|
conn.execute("COMMIT", [])
|
|
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
|
Ok(value)
|
|
}
|
|
Err(e) => {
|
|
conn.execute("ROLLBACK", [])
|
|
.map_err(|e| format!("Failed to rollback transaction: {}", e))?;
|
|
Err(e)
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
|
|
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
|
let conn = Arc::clone(&self.conn);
|
|
tokio::task::spawn_blocking(move || {
|
|
let conn = conn
|
|
.lock()
|
|
.map_err(|e| format!("Failed to lock connection: {}", e))?;
|
|
Ok(conn.last_insert_rowid())
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {}", e))?
|
|
}
|
|
}
|
|
|
|
// Helper functions for executing queries synchronously
|
|
|
|
fn execute_query(conn: &Connection, query: Query) -> DbResult<usize> {
|
|
let params = convert_params(&query.params);
|
|
conn.execute(&query.sql, params_from_iter(params.iter()))
|
|
.map_err(|e| format!("Execute failed: {}", e))
|
|
}
|
|
|
|
fn query_one<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<T>
|
|
where
|
|
F: Fn(&Row) -> SqliteResult<T>,
|
|
{
|
|
let params = convert_params(&query.params);
|
|
conn.query_row(&query.sql, params_from_iter(params.iter()), mapper)
|
|
.map_err(|e| format!("Query one failed: {}", e))
|
|
}
|
|
|
|
fn query_optional<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Option<T>>
|
|
where
|
|
F: Fn(&Row) -> SqliteResult<T>,
|
|
{
|
|
match query_one(conn, query, mapper) {
|
|
Ok(value) => Ok(Some(value)),
|
|
Err(e) if e.contains("Query returned no rows") || e.contains("QueryReturnedNoRows") => {
|
|
Ok(None)
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
fn query_many<T, F>(conn: &Connection, query: Query, mapper: F) -> DbResult<Vec<T>>
|
|
where
|
|
F: Fn(&Row) -> SqliteResult<T>,
|
|
{
|
|
let params = convert_params(&query.params);
|
|
let mut stmt = conn
|
|
.prepare(&query.sql)
|
|
.map_err(|e| format!("Prepare failed: {}", e))?;
|
|
|
|
let rows = stmt
|
|
.query_map(params_from_iter(params.iter()), mapper)
|
|
.map_err(|e| format!("Query map failed: {}", e))?;
|
|
|
|
rows.collect::<SqliteResult<Vec<T>>>()
|
|
.map_err(|e| format!("Collect failed: {}", e))
|
|
}
|
|
|
|
/// Convert QueryParam to rusqlite::types::Value
|
|
fn convert_params(params: &[QueryParam]) -> Vec<rusqlite::types::Value> {
|
|
params
|
|
.iter()
|
|
.map(|p| match p {
|
|
QueryParam::String(s) => rusqlite::types::Value::Text(s.clone()),
|
|
QueryParam::Int(i) => rusqlite::types::Value::Integer(*i as i64),
|
|
QueryParam::Int64(i) => rusqlite::types::Value::Integer(*i),
|
|
QueryParam::Float(f) => rusqlite::types::Value::Real(*f),
|
|
QueryParam::Bool(b) => rusqlite::types::Value::Integer(if *b { 1 } else { 0 }),
|
|
QueryParam::Null => rusqlite::types::Value::Null,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// TRACES: UR-002, UR-012 | DR-012 | UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-025
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_query() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
.unwrap();
|
|
|
|
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
|
|
|
let query = Query::with_params(
|
|
"INSERT INTO test (name) VALUES (?)",
|
|
vec![QueryParam::String("Alice".to_string())],
|
|
);
|
|
|
|
let rows = service.execute(query).await.unwrap();
|
|
assert_eq!(rows, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_one() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
.unwrap();
|
|
conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
|
|
.unwrap();
|
|
|
|
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
|
|
|
let query = Query::new("SELECT name FROM test WHERE id = 1");
|
|
let name: String = service.query_one(query, |row| row.get(0)).await.unwrap();
|
|
|
|
assert_eq!(name, "Bob");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_many() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
.unwrap();
|
|
conn.execute("INSERT INTO test (name) VALUES ('Alice')", [])
|
|
.unwrap();
|
|
conn.execute("INSERT INTO test (name) VALUES ('Bob')", [])
|
|
.unwrap();
|
|
|
|
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
|
|
|
let query = Query::new("SELECT name FROM test ORDER BY id");
|
|
let names: Vec<String> = service.query_many(query, |row| row.get(0)).await.unwrap();
|
|
|
|
assert_eq!(names, vec!["Alice", "Bob"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_optional() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
.unwrap();
|
|
|
|
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
|
|
|
let query = Query::new("SELECT name FROM test WHERE id = 999");
|
|
let result: Option<String> = service
|
|
.query_optional(query, |row| row.get(0))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_transaction() {
|
|
let conn = Connection::open_in_memory().unwrap();
|
|
conn.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
|
.unwrap();
|
|
|
|
let service = RusqliteService::new(Arc::new(Mutex::new(conn)));
|
|
|
|
let result = service
|
|
.transaction(|tx| {
|
|
tx.execute(Query::with_params(
|
|
"INSERT INTO test (name) VALUES (?)",
|
|
vec![QueryParam::String("Alice".to_string())],
|
|
))?;
|
|
tx.execute(Query::with_params(
|
|
"INSERT INTO test (name) VALUES (?)",
|
|
vec![QueryParam::String("Bob".to_string())],
|
|
))?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
|
|
// Verify both rows were inserted
|
|
let query = Query::new("SELECT COUNT(*) FROM test");
|
|
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
|
|
assert_eq!(count, 2);
|
|
}
|
|
}
|