use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde_json::{from_str, to_string}; use sqlx::{Error as SqlxError, QueryBuilder, query, query_as}; use crate::domain::RepositoryError; use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::listing::{ListRequest, Page, SortDirection}; use crate::domain::repositories::RoastRepository; use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::infrastructure::database::DatabasePool; use crate::infrastructure::repositories::macros::push_update_field; #[derive(Clone)] pub struct SqlRoastRepository { pool: DatabasePool, } impl SqlRoastRepository { pub fn new(pool: DatabasePool) -> Self { Self { pool } } fn order_clause(request: &ListRequest) -> String { let dir_sql = match request.sort_direction() { SortDirection::Asc => "ASC", SortDirection::Desc => "DESC", }; match request.sort_key() { RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"), RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"), RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"), RoastSortKey::Origin => { format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC") } RoastSortKey::Producer => { format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC") } } } fn encode_notes(notes: &[String]) -> Result, RepositoryError> { if notes.is_empty() { Ok(None) } else { to_string(notes).map(Some).map_err(|err| { RepositoryError::unexpected(format!("failed to encode tasting notes: {err}")) }) } } } fn empty_to_none(s: String) -> Option { if s.trim().is_empty() { None } else { Some(s) } } #[async_trait] impl RoastRepository for SqlRoastRepository { async fn insert(&self, new_roast: NewRoast) -> Result { let slug = new_roast.slug(); let NewRoast { roaster_id, name, origin, region, producer, tasting_notes, process, created_at, } = new_roast; let origin_value = empty_to_none(origin); let region_value = empty_to_none(region); let producer_value = empty_to_none(producer); let process_value = empty_to_none(process); let created_at = created_at.unwrap_or_else(Utc::now); let notes_json = Self::encode_notes(&tasting_notes)?; let record = query_as::<_, RoastRecord>( "INSERT INTO roasts (roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\ RETURNING id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at", ) .bind(i64::from(roaster_id)) .bind(&name) .bind(&slug) .bind(origin_value.as_deref()) .bind(region_value.as_deref()) .bind(producer_value.as_deref()) .bind(process_value.as_deref()) .bind(notes_json.as_deref()) .bind(created_at) .fetch_one(&self.pool) .await .map_err(|err| { if let sqlx::Error::Database(db_err) = &err && db_err.is_unique_violation() { return RepositoryError::conflict( "A roast with this name already exists for this roaster", ); } map_insert_error(err, "unknown roaster reference") })?; record.into_roast() } async fn get(&self, id: RoastId) -> Result { query_as::<_, RoastRecord>( "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", ) .bind(i64::from(id)) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? .map(RoastRecord::into_roast) .transpose()? .ok_or(RepositoryError::NotFound) } async fn get_with_roaster(&self, id: RoastId) -> Result { query_as::<_, RoastWithRoasterRecord>( "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \ FROM roasts r \ JOIN roasters ro ON ro.id = r.roaster_id \ WHERE r.id = ?", ) .bind(i64::from(id)) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? .map(RoastWithRoasterRecord::into_with_roaster) .transpose()? .ok_or(RepositoryError::NotFound) } async fn get_by_slug( &self, roaster_id: RoasterId, slug: &str, ) -> Result { query_as::<_, RoastRecord>( "SELECT id, roaster_id, name, slug, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE roaster_id = ? AND slug = ?", ) .bind(i64::from(roaster_id)) .bind(slug) .fetch_optional(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))? .map(RoastRecord::into_roast) .transpose()? .ok_or(RepositoryError::NotFound) } async fn list( &self, request: &ListRequest, search: Option<&str>, ) -> Result, RepositoryError> { use crate::infrastructure::repositories::pagination::SearchFilter; let order_clause = Self::order_clause(request); let base_query = "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id"; let count_query = "SELECT COUNT(*) FROM roasts r JOIN roasters ro ON ro.id = r.roaster_id"; let sf = search.and_then(|t| { SearchFilter::new( t, vec![ "r.name", "ro.name", "COALESCE(r.origin,'')", "COALESCE(r.producer,'')", "COALESCE(r.tasting_notes,'')", ], ) }); crate::infrastructure::repositories::pagination::paginate( &self.pool, request, base_query, count_query, &order_clause, sf.as_ref(), |record: RoastWithRoasterRecord| record.into_with_roaster(), ) .await } async fn list_by_roaster( &self, roaster_id: RoasterId, ) -> Result, RepositoryError> { let records = query_as::<_, RoastWithRoasterRecord>( "SELECT r.id, r.roaster_id, r.name, r.slug, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name, ro.slug AS roaster_slug \n FROM roasts r \n JOIN roasters ro ON ro.id = r.roaster_id \n WHERE r.roaster_id = ? \n ORDER BY r.created_at DESC", ) .bind(i64::from(roaster_id)) .fetch_all(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; records .into_iter() .map(RoastWithRoasterRecord::into_with_roaster) .collect() } async fn update(&self, id: RoastId, changes: UpdateRoast) -> Result { let mut tx = self .pool .begin() .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; let mut builder = QueryBuilder::new("UPDATE roasts SET "); let mut sep = false; // Handle roaster_id specially due to type conversion if let Some(roaster_id) = changes.roaster_id { sep = true; builder.push("roaster_id = "); builder.push_bind(i64::from(roaster_id)); } push_update_field!(builder, sep, "name", changes.name); push_update_field!(builder, sep, "origin", changes.origin); push_update_field!(builder, sep, "region", changes.region); push_update_field!(builder, sep, "producer", changes.producer); push_update_field!(builder, sep, "process", changes.process); push_update_field!(builder, sep, "created_at", changes.created_at); // Handle tasting_notes specially due to JSON encoding if let Some(tasting_notes) = changes.tasting_notes { let notes_json = Self::encode_notes(&tasting_notes)?; if sep { builder.push(", "); } sep = true; builder.push("tasting_notes = "); builder.push_bind(notes_json); } if !sep { return Err(RepositoryError::unexpected( "No fields provided for update".to_string(), )); } builder.push(" WHERE id = "); builder.push_bind(i64::from(id)); let result = builder .build() .execute(&mut *tx) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; if result.rows_affected() == 0 { return Err(RepositoryError::NotFound); } tx.commit() .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; self.get(id).await } async fn delete(&self, id: RoastId) -> Result<(), RepositoryError> { let result = query("DELETE FROM roasts WHERE id = ?") .bind(i64::from(id)) .execute(&self.pool) .await .map_err(|err| RepositoryError::unexpected(err.to_string()))?; if result.rows_affected() == 0 { return Err(RepositoryError::NotFound); } Ok(()) } } fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError { if let SqlxError::Database(db_err) = &err && db_err.code().as_deref() == Some("787") { return RepositoryError::conflict(message); } RepositoryError::unexpected(err.to_string()) } #[derive(sqlx::FromRow)] struct RoastRecord { id: i64, roaster_id: i64, name: String, slug: String, origin: Option, region: Option, producer: Option, process: Option, tasting_notes: Option, created_at: DateTime, } impl RoastRecord { fn into_roast(self) -> Result { let tasting_notes = match self.tasting_notes { Some(raw) => from_str::>(&raw).map_err(|err| { RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) })?, None => Vec::new(), }; Ok(Roast { id: RoastId::from(self.id), roaster_id: RoasterId::from(self.roaster_id), name: self.name, slug: self.slug, origin: self.origin, region: self.region, producer: self.producer, process: self.process, tasting_notes, created_at: self.created_at, }) } } #[derive(sqlx::FromRow)] struct RoastWithRoasterRecord { id: i64, roaster_id: i64, name: String, slug: String, origin: Option, region: Option, producer: Option, process: Option, tasting_notes: Option, created_at: DateTime, roaster_name: String, roaster_slug: String, } impl RoastWithRoasterRecord { fn into_with_roaster(self) -> Result { let tasting_notes = match self.tasting_notes { Some(raw) => from_str::>(&raw).map_err(|err| { RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) })?, None => Vec::new(), }; Ok(RoastWithRoaster { roast: Roast { id: RoastId::from(self.id), roaster_id: RoasterId::from(self.roaster_id), name: self.name, slug: self.slug, origin: self.origin, region: self.region, producer: self.producer, process: self.process, tasting_notes, created_at: self.created_at, }, roaster_name: self.roaster_name, roaster_slug: self.roaster_slug, }) } }