feat: add Dockerfile and implement SQLite persistence layer with repository pattern
This commit is contained in:
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM rust:1.92 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the release binary
|
||||||
|
RUN cargo build --release -p api
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install OpenSSL, CA certs, and ffmpeg (provides ffprobe for local-files duration scanning)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libssl3 \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/api .
|
||||||
|
|
||||||
|
|
||||||
|
# Create data directory for SQLite
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
ENV DATABASE_URL=sqlite:///app/data/template.db
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["./api"]
|
||||||
@@ -1,219 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
mod row;
|
||||||
use domain::{
|
pub mod repository;
|
||||||
RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong,
|
mod search;
|
||||||
SortField, SortOrder,
|
|
||||||
song_preview_chords,
|
|
||||||
};
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
fn sort_clause(field: SortField, order: SortOrder) -> &'static str {
|
pub use repository::{SqliteSongRepository, SqliteRepositoryFactory};
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
159
crates/infrastructure/persistence/src/repository.rs
Normal file
159
crates/infrastructure/persistence/src/repository.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
RepositoryError, Song, SongRepositoryPort, SongSummary, StoredSong,
|
||||||
|
SortField, SortOrder, song_preview_chords,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SqliteSongRepository {
|
||||||
|
pub(crate) 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteRepositoryFactory;
|
||||||
|
|
||||||
|
impl SqliteRepositoryFactory {
|
||||||
|
pub async fn create(database_url: &str) -> Result<SqliteSongRepository, sqlx::Error> {
|
||||||
|
SqliteSongRepository::new(database_url).await
|
||||||
|
}
|
||||||
|
}
|
||||||
42
crates/infrastructure/persistence/src/row.rs
Normal file
42
crates/infrastructure/persistence/src/row.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use domain::{RepositoryError, SongMeta, SongSummary, SortField, SortOrder};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub(crate) struct SongRow {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) title: String,
|
||||||
|
pub(crate) artist: String,
|
||||||
|
pub(crate) original_key: Option<String>,
|
||||||
|
pub(crate) preview_chords: String,
|
||||||
|
pub(crate) body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
26
crates/infrastructure/persistence/src/search.rs
Normal file
26
crates/infrastructure/persistence/src/search.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{RepositoryError, SongSearchPort, SongSummary, SortField, SortOrder};
|
||||||
|
|
||||||
|
use crate::repository::SqliteSongRepository;
|
||||||
|
use crate::row::{SongRow, sort_clause, row_to_summary};
|
||||||
|
|
||||||
|
#[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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user