feat(persistence): add SqliteSongRepository and SqliteRepositoryFactory
This commit is contained in:
3645
Cargo.lock
generated
Normal file
3645
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ tracing = "0.1.44"
|
|||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
uuid = { version = "1.23.0", features = ["v4", "serde"] }
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls"] }
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] }
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
scraper = "0.23"
|
scraper = "0.23"
|
||||||
|
|
||||||
|
|||||||
19
crates/infrastructure/persistence/Cargo.toml
Normal file
19
crates/infrastructure/persistence/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "persistence"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
domain = { path = "../../domain" }
|
||||||
|
common = { path = "../../common" }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS songs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
original_key TEXT,
|
||||||
|
preview_chords TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
131
crates/infrastructure/persistence/src/lib.rs
Normal file
131
crates/infrastructure/persistence/src/lib.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSummary, StoredSong,
|
||||||
|
song_preview_chords,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
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) -> Result<Vec<SongSummary>, RepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, SongRow>(
|
||||||
|
"SELECT id, title, artist, original_key, preview_chords, body FROM songs ORDER BY created_at DESC"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| RepositoryError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteRepositoryFactory;
|
||||||
|
|
||||||
|
impl SqliteRepositoryFactory {
|
||||||
|
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
||||||
|
SqliteSongRepository::new(database_url).await
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user