Files
pocket-chords/crates/infrastructure/persistence/src/lib.rs

220 lines
7.4 KiB
Rust

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<Self, sqlx::Error> {
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<String>,
preview_chords: String,
body: String,
}
#[async_trait]
impl SongRepositoryPort for SqliteSongRepository {
async fn save(&self, song: &Song) -> Result<StoredSong, RepositoryError> {
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<Vec<SongSummary>, 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<Option<Song>, 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<SongSummary, 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()))?
.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<String> = 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<Vec<SongSummary>, 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<SongSummary, RepositoryError> {
let id = Uuid::parse_str(&row.id)
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
let preview_chords: Vec<String> = 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, sqlx::Error> {
SqliteSongRepository::new(database_url).await
}
}