use async_trait::async_trait; use domain::{ RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder, song_preview_chords, }; use sqlx::SqlitePool; use uuid::Uuid; fn sort_clause(field: SortField, order: SortOrder) -> &'static str { match (field, order) { (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", (SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC", (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", } } #[derive(Clone)] pub struct SqliteSongRepository { pool: SqlitePool, } impl SqliteSongRepository { pub async fn new(database_url: &str) -> Result { let pool = SqlitePool::connect(database_url).await?; sqlx::migrate!("./migrations").run(&pool).await?; Ok(Self { pool }) } } #[derive(sqlx::FromRow)] struct SongRow { id: String, title: String, artist: String, original_key: Option, preview_chords: String, body: String, } #[async_trait] impl SongRepositoryPort for SqliteSongRepository { async fn save(&self, song: &Song) -> Result { let id = Uuid::new_v4(); let id_str = id.to_string(); let body = serde_json::to_string(song) .map_err(|e| RepositoryError::Internal(e.to_string()))?; let preview = song_preview_chords(song); let preview_json = serde_json::to_string(&preview) .map_err(|e| RepositoryError::Internal(e.to_string()))?; let original_key = song.meta.original_key.as_deref(); sqlx::query( "INSERT INTO songs (id, title, artist, original_key, preview_chords, body) VALUES (?, ?, ?, ?, ?, ?)" ) .bind(&id_str) .bind(&song.meta.title) .bind(&song.meta.artist) .bind(original_key) .bind(&preview_json) .bind(&body) .execute(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; Ok(StoredSong { id, song: song.clone() }) } async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { let sql = format!( "SELECT id, title, artist, original_key, preview_chords, body FROM songs {}", sort_clause(sort, order) ); let rows = sqlx::query_as::<_, SongRow>(&sql) .fetch_all(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; rows.into_iter().map(row_to_summary).collect() } async fn get(&self, id: Uuid) -> Result, RepositoryError> { let id_str = id.to_string(); let row = sqlx::query_as::<_, SongRow>( "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" ) .bind(&id_str) .fetch_optional(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; match row { None => Ok(None), Some(r) => { let song: Song = serde_json::from_str(&r.body) .map_err(|e| RepositoryError::Internal(e.to_string()))?; Ok(Some(song)) } } } async fn delete(&self, id: Uuid) -> Result<(), RepositoryError> { let id_str = id.to_string(); let result = sqlx::query("DELETE FROM songs WHERE id = ?") .bind(&id_str) .execute(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; if result.rows_affected() == 0 { Err(RepositoryError::NotFound) } else { Ok(()) } } async fn update_meta( &self, id: Uuid, title: Option<&str>, artist: Option<&str>, original_key: Option<&str>, ) -> Result { let id_str = id.to_string(); let row = sqlx::query_as::<_, SongRow>( "SELECT id, title, artist, original_key, preview_chords, body FROM songs WHERE id = ?" ) .bind(&id_str) .fetch_optional(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))? .ok_or(RepositoryError::NotFound)?; let mut song: Song = serde_json::from_str(&row.body) .map_err(|e| RepositoryError::Internal(e.to_string()))?; if let Some(t) = title { song.meta.title = t.to_string(); } if let Some(a) = artist { song.meta.artist = a.to_string(); } if let Some(k) = original_key { song.meta.original_key = Some(k.to_string()); } let new_body = serde_json::to_string(&song) .map_err(|e| RepositoryError::Internal(e.to_string()))?; let new_title = title.unwrap_or(&row.title); let new_artist = artist.unwrap_or(&row.artist); let new_key: Option<&str> = original_key.or(row.original_key.as_deref()); sqlx::query( "UPDATE songs SET title = ?, artist = ?, original_key = ?, body = ? WHERE id = ?" ) .bind(new_title) .bind(new_artist) .bind(new_key) .bind(&new_body) .bind(&id_str) .execute(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; let preview_chords: Vec = serde_json::from_str(&row.preview_chords) .map_err(|e| RepositoryError::Internal(e.to_string()))?; Ok(SongSummary { id, meta: song.meta, preview_chords, }) } } #[async_trait] impl SongSearchPort for SqliteSongRepository { async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError> { let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); let pattern = format!("%{}%", escaped); let sql = format!( "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}", sort_clause(sort, order) ); let rows = sqlx::query_as::<_, SongRow>(&sql) .bind(&pattern) .bind(&pattern) .fetch_all(&self.pool) .await .map_err(|e| RepositoryError::Internal(e.to_string()))?; rows.into_iter().map(row_to_summary).collect() } } fn row_to_summary(row: SongRow) -> Result { let id = Uuid::parse_str(&row.id) .map_err(|e| RepositoryError::Internal(e.to_string()))?; let preview_chords: Vec = serde_json::from_str(&row.preview_chords) .map_err(|e| RepositoryError::Internal(e.to_string()))?; Ok(SongSummary { id, meta: SongMeta { title: row.title, artist: row.artist, original_key: row.original_key, capo: None, tuning: None, tempo: None, }, preview_chords, }) } pub struct SqliteRepositoryFactory; impl SqliteRepositoryFactory { pub async fn create(database_url: &str) -> Result { SqliteSongRepository::new(database_url).await } }