refactor: convert to_domain methods to From/TryFrom trait impls

Replace ad-hoc to_domain/into_domain conversion methods on SQL
repository structs with idiomatic From and TryFrom trait implementations
on the record types, following standard Rust conventions.
This commit is contained in:
Jon Seager 2026-02-13 13:35:08 +00:00
parent 590868e51f
commit 7330e5b59e
No known key found for this signature in database
13 changed files with 345 additions and 370 deletions

View file

@ -17,20 +17,6 @@ impl SqlAiUsageRepository {
pub fn new(pool: DatabasePool) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } Self { pool }
} }
fn to_domain(record: AiUsageRecord) -> AiUsage {
AiUsage {
id: AiUsageId::from(record.id),
user_id: UserId::from(record.user_id),
model: record.model,
endpoint: record.endpoint,
prompt_tokens: record.prompt_tokens,
completion_tokens: record.completion_tokens,
total_tokens: record.total_tokens,
cost: record.cost,
created_at: record.created_at,
}
}
} }
#[async_trait] #[async_trait]
@ -54,7 +40,7 @@ impl AiUsageRepository for SqlAiUsageRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn summary_for_user(&self, user_id: UserId) -> Result<AiUsageSummary, RepositoryError> { async fn summary_for_user(&self, user_id: UserId) -> Result<AiUsageSummary, RepositoryError> {
@ -98,6 +84,22 @@ struct AiUsageRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
impl From<AiUsageRecord> for AiUsage {
fn from(record: AiUsageRecord) -> Self {
AiUsage {
id: AiUsageId::from(record.id),
user_id: UserId::from(record.user_id),
model: record.model,
endpoint: record.endpoint,
prompt_tokens: record.prompt_tokens,
completion_tokens: record.completion_tokens,
total_tokens: record.total_tokens,
cost: record.cost,
created_at: record.created_at,
}
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
#[allow(clippy::struct_field_names)] #[allow(clippy::struct_field_names)]
struct AiUsageSummaryRecord { struct AiUsageSummaryRecord {

View file

@ -17,26 +17,6 @@ impl SqlPasskeyCredentialRepository {
pub fn new(pool: DatabasePool) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } Self { pool }
} }
fn to_domain(record: PasskeyCredentialRecord) -> PasskeyCredential {
let PasskeyCredentialRecord {
id,
user_id,
credential_json,
name,
created_at,
last_used_at,
} = record;
PasskeyCredential {
id: PasskeyCredentialId::from(id),
user_id: UserId::from(user_id),
credential_json,
name,
created_at,
last_used_at,
}
}
} }
#[async_trait] #[async_trait]
@ -61,7 +41,7 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
RepositoryError::unexpected(format!("failed to insert passkey credential: {err}")) RepositoryError::unexpected(format!("failed to insert passkey credential: {err}"))
})?; })?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: PasskeyCredentialId) -> Result<PasskeyCredential, RepositoryError> { async fn get(&self, id: PasskeyCredentialId) -> Result<PasskeyCredential, RepositoryError> {
@ -82,7 +62,7 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
} }
})?; })?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn list_by_user( async fn list_by_user(
@ -104,7 +84,7 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
RepositoryError::unexpected(format!("failed to list passkey credentials: {err}")) RepositoryError::unexpected(format!("failed to list passkey credentials: {err}"))
})?; })?;
Ok(records.into_iter().map(Self::to_domain).collect()) Ok(records.into_iter().map(Into::into).collect())
} }
async fn list_all(&self) -> Result<Vec<PasskeyCredential>, RepositoryError> { async fn list_all(&self) -> Result<Vec<PasskeyCredential>, RepositoryError> {
@ -123,7 +103,7 @@ impl PasskeyCredentialRepository for SqlPasskeyCredentialRepository {
)) ))
})?; })?;
Ok(records.into_iter().map(Self::to_domain).collect()) Ok(records.into_iter().map(Into::into).collect())
} }
async fn update_credential_json( async fn update_credential_json(
@ -183,3 +163,16 @@ struct PasskeyCredentialRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>, last_used_at: Option<DateTime<Utc>>,
} }
impl From<PasskeyCredentialRecord> for PasskeyCredential {
fn from(record: PasskeyCredentialRecord) -> Self {
PasskeyCredential {
id: PasskeyCredentialId::from(record.id),
user_id: UserId::from(record.user_id),
credential_json: record.credential_json,
name: record.name,
created_at: record.created_at,
last_used_at: record.last_used_at,
}
}
}

View file

@ -17,26 +17,6 @@ impl SqlRegistrationTokenRepository {
pub fn new(pool: DatabasePool) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } Self { pool }
} }
fn to_domain(record: RegistrationTokenRecord) -> RegistrationToken {
let RegistrationTokenRecord {
id,
token_hash,
created_at,
expires_at,
used_at,
used_by_user_id,
} = record;
RegistrationToken {
id: RegistrationTokenId::from(id),
token_hash,
created_at,
expires_at,
used_at,
used_by_user_id: used_by_user_id.map(UserId::from),
}
}
} }
#[async_trait] #[async_trait]
@ -61,7 +41,7 @@ impl RegistrationTokenRepository for SqlRegistrationTokenRepository {
RepositoryError::unexpected(format!("failed to insert registration token: {err}")) RepositoryError::unexpected(format!("failed to insert registration token: {err}"))
})?; })?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_by_token_hash( async fn get_by_token_hash(
@ -83,7 +63,7 @@ impl RegistrationTokenRepository for SqlRegistrationTokenRepository {
})? })?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn mark_used( async fn mark_used(
@ -119,3 +99,16 @@ struct RegistrationTokenRecord {
used_at: Option<DateTime<Utc>>, used_at: Option<DateTime<Utc>>,
used_by_user_id: Option<i64>, used_by_user_id: Option<i64>,
} }
impl From<RegistrationTokenRecord> for RegistrationToken {
fn from(record: RegistrationTokenRecord) -> Self {
RegistrationToken {
id: RegistrationTokenId::from(record.id),
token_hash: record.token_hash,
created_at: record.created_at,
expires_at: record.expires_at,
used_at: record.used_at,
used_by_user_id: record.used_by_user_id.map(UserId::from),
}
}
}

View file

@ -16,24 +16,6 @@ impl SqlSessionRepository {
pub fn new(pool: DatabasePool) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } Self { pool }
} }
fn to_domain(record: SessionRecord) -> Session {
let SessionRecord {
id,
user_id,
session_token_hash,
created_at,
expires_at,
} = record;
Session::new(
SessionId::from(id),
UserId::from(user_id),
session_token_hash,
created_at,
expires_at,
)
}
} }
#[async_trait] #[async_trait]
@ -59,7 +41,7 @@ impl SessionRepository for SqlSessionRepository {
RepositoryError::unexpected(format!("failed to insert session: {err}")) RepositoryError::unexpected(format!("failed to insert session: {err}"))
})?; })?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: SessionId) -> Result<Session, RepositoryError> { async fn get(&self, id: SessionId) -> Result<Session, RepositoryError> {
@ -72,7 +54,7 @@ impl SessionRepository for SqlSessionRepository {
.map_err(|err| RepositoryError::unexpected(format!("failed to get session: {err}")))? .map_err(|err| RepositoryError::unexpected(format!("failed to get session: {err}")))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError> { async fn get_by_token_hash(&self, token_hash: &str) -> Result<Session, RepositoryError> {
@ -87,7 +69,7 @@ impl SessionRepository for SqlSessionRepository {
})? })?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn delete(&self, id: SessionId) -> Result<(), RepositoryError> { async fn delete(&self, id: SessionId) -> Result<(), RepositoryError> {
@ -124,3 +106,15 @@ struct SessionRecord {
created_at: chrono::DateTime<Utc>, created_at: chrono::DateTime<Utc>,
expires_at: chrono::DateTime<Utc>, expires_at: chrono::DateTime<Utc>,
} }
impl From<SessionRecord> for Session {
fn from(record: SessionRecord) -> Self {
Session::new(
SessionId::from(record.id),
UserId::from(record.user_id),
record.session_token_hash,
record.created_at,
record.expires_at,
)
}
}

View file

@ -17,28 +17,6 @@ impl SqlTokenRepository {
pub fn new(pool: DatabasePool) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } Self { pool }
} }
fn to_domain(record: TokenRecord) -> Token {
let TokenRecord {
id,
user_id,
token_hash,
name,
created_at,
last_used_at,
revoked_at,
} = record;
Token::new(
TokenId::from(id),
UserId::from(user_id),
token_hash,
name,
created_at,
last_used_at,
revoked_at,
)
}
} }
#[async_trait] #[async_trait]
@ -67,7 +45,7 @@ impl TokenRepository for SqlTokenRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: TokenId) -> Result<Token, RepositoryError> { async fn get(&self, id: TokenId) -> Result<Token, RepositoryError> {
@ -80,7 +58,7 @@ impl TokenRepository for SqlTokenRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError> { async fn get_by_token_hash(&self, token_hash: &str) -> Result<Token, RepositoryError> {
@ -93,7 +71,7 @@ impl TokenRepository for SqlTokenRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError> { async fn list_by_user(&self, user_id: UserId) -> Result<Vec<Token>, RepositoryError> {
@ -105,7 +83,7 @@ impl TokenRepository for SqlTokenRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(records.into_iter().map(Self::to_domain).collect()) Ok(records.into_iter().map(Into::into).collect())
} }
async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError> { async fn revoke(&self, id: TokenId) -> Result<Token, RepositoryError> {
@ -120,7 +98,7 @@ impl TokenRepository for SqlTokenRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Ok(Self::to_domain(record)), Some(record) => Ok(record.into()),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -149,3 +127,17 @@ struct TokenRecord {
last_used_at: Option<DateTime<Utc>>, last_used_at: Option<DateTime<Utc>>,
revoked_at: Option<DateTime<Utc>>, revoked_at: Option<DateTime<Utc>>,
} }
impl From<TokenRecord> for Token {
fn from(record: TokenRecord) -> Self {
Token::new(
TokenId::from(record.id),
UserId::from(record.user_id),
record.token_hash,
record.name,
record.created_at,
record.last_used_at,
record.revoked_at,
)
}
}

View file

@ -17,17 +17,6 @@ impl SqlUserRepository {
pub fn new(pool: DatabasePool) -> Self { pub fn new(pool: DatabasePool) -> Self {
Self { pool } Self { pool }
} }
fn to_domain(record: UserRecord) -> User {
let UserRecord {
id,
username,
uuid,
created_at,
} = record;
User::new(UserId::from(id), username, uuid, created_at)
}
} }
#[async_trait] #[async_trait]
@ -49,7 +38,7 @@ impl UserRepository for SqlUserRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: UserId) -> Result<User, RepositoryError> { async fn get(&self, id: UserId) -> Result<User, RepositoryError> {
@ -62,7 +51,7 @@ impl UserRepository for SqlUserRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> { async fn get_by_username(&self, username: &str) -> Result<User, RepositoryError> {
@ -75,7 +64,7 @@ impl UserRepository for SqlUserRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_by_uuid(&self, uuid: &str) -> Result<User, RepositoryError> { async fn get_by_uuid(&self, uuid: &str) -> Result<User, RepositoryError> {
@ -88,7 +77,7 @@ impl UserRepository for SqlUserRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn exists(&self) -> Result<bool, RepositoryError> { async fn exists(&self) -> Result<bool, RepositoryError> {
@ -110,7 +99,7 @@ impl UserRepository for SqlUserRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(records.into_iter().map(Self::to_domain).collect()) Ok(records.into_iter().map(Into::into).collect())
} }
} }
@ -121,3 +110,14 @@ struct UserRecord {
uuid: String, uuid: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
impl From<UserRecord> for User {
fn from(record: UserRecord) -> Self {
User::new(
UserId::from(record.id),
record.username,
record.uuid,
record.created_at,
)
}
}

View file

@ -11,7 +11,7 @@ use crate::infrastructure::database::DatabasePool;
use crate::infrastructure::repositories::macros::push_update_field; use crate::infrastructure::repositories::macros::push_update_field;
const BASE_SELECT: &str = r" const BASE_SELECT: &str = r"
SELECT SELECT
b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at, b.id, b.roast_id, b.roast_date, b.amount, b.remaining, b.closed, b.finished_at, b.created_at, b.updated_at,
r.name as roast_name, r.slug as roast_slug, r.name as roast_name, r.slug as roast_slug,
rr.name as roaster_name, rr.slug as roaster_slug rr.name as roaster_name, rr.slug as roaster_slug
@ -49,40 +49,6 @@ impl SqlBagRepository {
} }
} }
fn to_domain(record: BagRecord) -> Bag {
Bag {
id: BagId::new(record.id),
roast_id: RoastId::new(record.roast_id),
roast_date: record.roast_date,
amount: record.amount,
remaining: record.remaining,
closed: record.closed,
finished_at: record.finished_at,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn to_domain_with_roast(record: BagWithRoastRecord) -> BagWithRoast {
BagWithRoast {
bag: Bag {
id: BagId::new(record.id),
roast_id: RoastId::new(record.roast_id),
roast_date: record.roast_date,
amount: record.amount,
remaining: record.remaining,
closed: record.closed,
finished_at: record.finished_at,
created_at: record.created_at,
updated_at: record.updated_at,
},
roast_name: record.roast_name,
roaster_name: record.roaster_name,
roast_slug: record.roast_slug,
roaster_slug: record.roaster_slug,
}
}
fn build_where_clause(filter: &BagFilter) -> Option<String> { fn build_where_clause(filter: &BagFilter) -> Option<String> {
let mut conditions = Vec::new(); let mut conditions = Vec::new();
@ -130,7 +96,7 @@ impl BagRepository for SqlBagRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: BagId) -> Result<Bag, RepositoryError> { async fn get(&self, id: BagId) -> Result<Bag, RepositoryError> {
@ -147,7 +113,7 @@ impl BagRepository for SqlBagRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError> { async fn get_with_roast(&self, id: BagId) -> Result<BagWithRoast, RepositoryError> {
@ -160,7 +126,7 @@ impl BagRepository for SqlBagRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain_with_roast(record)) Ok(record.into())
} }
async fn list( async fn list(
@ -197,7 +163,7 @@ impl BagRepository for SqlBagRepository {
&count_query, &count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
|record| Ok(Self::to_domain_with_roast(record)), |record: BagWithRoastRecord| Ok(record.into()),
) )
.await .await
} }
@ -233,7 +199,7 @@ impl BagRepository for SqlBagRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn delete(&self, id: BagId) -> Result<(), RepositoryError> { async fn delete(&self, id: BagId) -> Result<(), RepositoryError> {
@ -266,6 +232,22 @@ struct BagRecord {
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
} }
impl From<BagRecord> for Bag {
fn from(record: BagRecord) -> Self {
Bag {
id: BagId::new(record.id),
roast_id: RoastId::new(record.roast_id),
roast_date: record.roast_date,
amount: record.amount,
remaining: record.remaining,
closed: record.closed,
finished_at: record.finished_at,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct BagWithRoastRecord { struct BagWithRoastRecord {
id: i64, id: i64,
@ -282,3 +264,25 @@ struct BagWithRoastRecord {
roaster_name: String, roaster_name: String,
roaster_slug: String, roaster_slug: String,
} }
impl From<BagWithRoastRecord> for BagWithRoast {
fn from(record: BagWithRoastRecord) -> Self {
BagWithRoast {
bag: Bag {
id: BagId::new(record.id),
roast_id: RoastId::new(record.roast_id),
roast_date: record.roast_date,
amount: record.amount,
remaining: record.remaining,
closed: record.closed,
finished_at: record.finished_at,
created_at: record.created_at,
updated_at: record.updated_at,
},
roast_name: record.roast_name,
roaster_name: record.roaster_name,
roast_slug: record.roast_slug,
roaster_slug: record.roaster_slug,
}
}
}

View file

@ -33,6 +33,17 @@ const BASE_SELECT: &str = r"
LEFT JOIN gear g_fp ON br.filter_paper_id = g_fp.id LEFT JOIN gear g_fp ON br.filter_paper_id = g_fp.id
"; ";
fn decode_quick_notes(raw: Option<String>) -> Vec<QuickNote> {
match raw {
Some(s) if !s.is_empty() => serde_json::from_str::<Vec<String>>(&s)
.unwrap_or_default()
.iter()
.filter_map(|v| QuickNote::from_str_value(v))
.collect(),
_ => Vec::new(),
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct SqlBrewRepository { pub struct SqlBrewRepository {
pool: DatabasePool, pool: DatabasePool,
@ -56,17 +67,6 @@ impl SqlBrewRepository {
} }
} }
fn decode_quick_notes(raw: Option<String>) -> Vec<QuickNote> {
match raw {
Some(s) if !s.is_empty() => serde_json::from_str::<Vec<String>>(&s)
.unwrap_or_default()
.iter()
.filter_map(|v| QuickNote::from_str_value(v))
.collect(),
_ => Vec::new(),
}
}
fn encode_quick_notes(notes: &[QuickNote]) -> Option<String> { fn encode_quick_notes(notes: &[QuickNote]) -> Option<String> {
if notes.is_empty() { if notes.is_empty() {
None None
@ -82,52 +82,6 @@ impl SqlBrewRepository {
} }
} }
fn to_domain(record: BrewRecord) -> Brew {
Brew {
id: BrewId::new(record.id),
bag_id: BagId::new(record.bag_id),
coffee_weight: record.coffee_weight,
grinder_id: GearId::new(record.grinder_id),
grind_setting: record.grind_setting,
brewer_id: GearId::new(record.brewer_id),
filter_paper_id: record.filter_paper_id.map(GearId::new),
water_volume: record.water_volume,
water_temp: record.water_temp,
quick_notes: Self::decode_quick_notes(record.quick_notes),
brew_time: record.brew_time,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn to_domain_with_details(record: BrewWithDetailsRecord) -> BrewWithDetails {
BrewWithDetails {
brew: Brew {
id: BrewId::new(record.id),
bag_id: BagId::new(record.bag_id),
coffee_weight: record.coffee_weight,
grinder_id: GearId::new(record.grinder_id),
grind_setting: record.grind_setting,
brewer_id: GearId::new(record.brewer_id),
filter_paper_id: record.filter_paper_id.map(GearId::new),
water_volume: record.water_volume,
water_temp: record.water_temp,
quick_notes: Self::decode_quick_notes(record.quick_notes),
brew_time: record.brew_time,
created_at: record.created_at,
updated_at: record.updated_at,
},
roast_name: record.roast_name,
roaster_name: record.roaster_name,
roast_slug: record.roast_slug,
roaster_slug: record.roaster_slug,
grinder_name: record.grinder_name,
grinder_model: record.grinder_model,
brewer_name: record.brewer_name,
filter_paper_name: record.filter_paper_name,
}
}
fn build_where_clause(filter: &BrewFilter) -> Option<String> { fn build_where_clause(filter: &BrewFilter) -> Option<String> {
// SAFETY: Direct interpolation is safe here because `bag_id` is an i64 from a typed wrapper. // SAFETY: Direct interpolation is safe here because `bag_id` is an i64 from a typed wrapper.
filter filter
@ -201,7 +155,7 @@ impl BrewRepository for SqlBrewRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError> { async fn get(&self, id: BrewId) -> Result<Brew, RepositoryError> {
@ -218,7 +172,7 @@ impl BrewRepository for SqlBrewRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError> { async fn get_with_details(&self, id: BrewId) -> Result<BrewWithDetails, RepositoryError> {
@ -231,7 +185,7 @@ impl BrewRepository for SqlBrewRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain_with_details(record)) Ok(record.into())
} }
async fn list( async fn list(
@ -271,7 +225,7 @@ impl BrewRepository for SqlBrewRepository {
&count_query, &count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
|record| Ok(Self::to_domain_with_details(record)), |record: BrewWithDetailsRecord| Ok(record.into()),
) )
.await .await
} }
@ -339,7 +293,7 @@ impl BrewRepository for SqlBrewRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn delete(&self, id: BrewId) -> Result<(), RepositoryError> { async fn delete(&self, id: BrewId) -> Result<(), RepositoryError> {
@ -376,6 +330,26 @@ struct BrewRecord {
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
} }
impl From<BrewRecord> for Brew {
fn from(record: BrewRecord) -> Self {
Brew {
id: BrewId::new(record.id),
bag_id: BagId::new(record.bag_id),
coffee_weight: record.coffee_weight,
grinder_id: GearId::new(record.grinder_id),
grind_setting: record.grind_setting,
brewer_id: GearId::new(record.brewer_id),
filter_paper_id: record.filter_paper_id.map(GearId::new),
water_volume: record.water_volume,
water_temp: record.water_temp,
quick_notes: decode_quick_notes(record.quick_notes),
brew_time: record.brew_time,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct BrewWithDetailsRecord { struct BrewWithDetailsRecord {
id: i64, id: i64,
@ -400,3 +374,33 @@ struct BrewWithDetailsRecord {
brewer_name: String, brewer_name: String,
filter_paper_name: Option<String>, filter_paper_name: Option<String>,
} }
impl From<BrewWithDetailsRecord> for BrewWithDetails {
fn from(record: BrewWithDetailsRecord) -> Self {
BrewWithDetails {
brew: Brew {
id: BrewId::new(record.id),
bag_id: BagId::new(record.bag_id),
coffee_weight: record.coffee_weight,
grinder_id: GearId::new(record.grinder_id),
grind_setting: record.grind_setting,
brewer_id: GearId::new(record.brewer_id),
filter_paper_id: record.filter_paper_id.map(GearId::new),
water_volume: record.water_volume,
water_temp: record.water_temp,
quick_notes: decode_quick_notes(record.quick_notes),
brew_time: record.brew_time,
created_at: record.created_at,
updated_at: record.updated_at,
},
roast_name: record.roast_name,
roaster_name: record.roaster_name,
roast_slug: record.roast_slug,
roaster_slug: record.roaster_slug,
grinder_name: record.grinder_name,
grinder_model: record.grinder_model,
brewer_name: record.brewer_name,
filter_paper_name: record.filter_paper_name,
}
}
}

View file

@ -33,34 +33,6 @@ impl SqlCafeRepository {
CafeSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"), CafeSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"),
} }
} }
fn into_domain(record: CafeRecord) -> Cafe {
let CafeRecord {
id,
name,
slug,
city,
country,
latitude,
longitude,
website,
created_at,
updated_at,
} = record;
Cafe {
id: CafeId::from(id),
name,
slug,
city,
country,
latitude,
longitude,
website,
created_at,
updated_at,
}
}
} }
#[async_trait] #[async_trait]
@ -96,7 +68,7 @@ impl CafeRepository for SqlCafeRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Ok(Self::into_domain(record)) Ok(record.into())
} }
async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError> { async fn get(&self, id: CafeId) -> Result<Cafe, RepositoryError> {
@ -109,7 +81,7 @@ impl CafeRepository for SqlCafeRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Ok(Self::into_domain(record)), Some(record) => Ok(record.into()),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -124,7 +96,7 @@ impl CafeRepository for SqlCafeRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Ok(Self::into_domain(record)), Some(record) => Ok(record.into()),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -148,7 +120,7 @@ impl CafeRepository for SqlCafeRepository {
count_query, count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
|record| Ok(Self::into_domain(record)), |record: CafeRecord| Ok(record.into()),
) )
.await .await
} }
@ -210,3 +182,20 @@ struct CafeRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
} }
impl From<CafeRecord> for Cafe {
fn from(record: CafeRecord) -> Self {
Cafe {
id: CafeId::from(record.id),
name: record.name,
slug: record.slug,
city: record.city,
country: record.country,
latitude: record.latitude,
longitude: record.longitude,
website: record.website,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}

View file

@ -57,35 +57,6 @@ impl SqlCupRepository {
} }
} }
fn to_domain(record: CupRecord) -> Cup {
Cup {
id: CupId::new(record.id),
roast_id: RoastId::new(record.roast_id),
cafe_id: CafeId::new(record.cafe_id),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn to_domain_with_details(record: CupWithDetailsRecord) -> CupWithDetails {
CupWithDetails {
cup: Cup {
id: CupId::new(record.id),
roast_id: RoastId::new(record.roast_id),
cafe_id: CafeId::new(record.cafe_id),
created_at: record.created_at,
updated_at: record.updated_at,
},
roast_name: record.roast_name,
roaster_name: record.roaster_name,
roast_slug: record.roast_slug,
roaster_slug: record.roaster_slug,
cafe_name: record.cafe_name,
cafe_slug: record.cafe_slug,
cafe_city: record.cafe_city,
}
}
fn build_where_clause(filter: &CupFilter) -> Option<String> { fn build_where_clause(filter: &CupFilter) -> Option<String> {
let mut conditions = Vec::new(); let mut conditions = Vec::new();
@ -121,7 +92,7 @@ impl CupRepository for SqlCupRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn get(&self, id: CupId) -> Result<Cup, RepositoryError> { async fn get(&self, id: CupId) -> Result<Cup, RepositoryError> {
@ -134,7 +105,7 @@ impl CupRepository for SqlCupRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Ok(Self::to_domain(record)), Some(record) => Ok(record.into()),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -149,7 +120,7 @@ impl CupRepository for SqlCupRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain_with_details(record)) Ok(record.into())
} }
async fn list( async fn list(
@ -189,7 +160,7 @@ impl CupRepository for SqlCupRepository {
&count_query, &count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
|record| Ok(Self::to_domain_with_details(record)), |record: CupWithDetailsRecord| Ok(record.into()),
) )
.await .await
} }
@ -226,7 +197,7 @@ impl CupRepository for SqlCupRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Ok(Self::to_domain(record)) Ok(record.into())
} }
async fn delete(&self, id: CupId) -> Result<(), RepositoryError> { async fn delete(&self, id: CupId) -> Result<(), RepositoryError> {
@ -253,6 +224,18 @@ struct CupRecord {
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
} }
impl From<CupRecord> for Cup {
fn from(record: CupRecord) -> Self {
Cup {
id: CupId::new(record.id),
roast_id: RoastId::new(record.roast_id),
cafe_id: CafeId::new(record.cafe_id),
created_at: record.created_at,
updated_at: record.updated_at,
}
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct CupWithDetailsRecord { struct CupWithDetailsRecord {
id: i64, id: i64,
@ -268,3 +251,24 @@ struct CupWithDetailsRecord {
cafe_slug: String, cafe_slug: String,
cafe_city: String, cafe_city: String,
} }
impl From<CupWithDetailsRecord> for CupWithDetails {
fn from(record: CupWithDetailsRecord) -> Self {
CupWithDetails {
cup: Cup {
id: CupId::new(record.id),
roast_id: RoastId::new(record.roast_id),
cafe_id: CafeId::new(record.cafe_id),
created_at: record.created_at,
updated_at: record.updated_at,
},
roast_name: record.roast_name,
roaster_name: record.roaster_name,
roast_slug: record.roast_slug,
roaster_slug: record.roaster_slug,
cafe_name: record.cafe_name,
cafe_slug: record.cafe_slug,
cafe_city: record.cafe_city,
}
}
}

View file

@ -36,21 +36,6 @@ impl SqlGearRepository {
} }
} }
fn to_domain(record: GearRecord) -> Result<Gear, RepositoryError> {
let category = GearCategory::from_str(&record.category).map_err(|()| {
RepositoryError::unexpected(format!("invalid category: {}", record.category))
})?;
Ok(Gear {
id: GearId::new(record.id),
category,
make: record.make,
model: record.model,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
fn build_where_clause(filter: &GearFilter) -> Option<&'static str> { fn build_where_clause(filter: &GearFilter) -> Option<&'static str> {
filter.category.as_ref().map(|category| match category { filter.category.as_ref().map(|category| match category {
GearCategory::Grinder => "category = 'grinder'", GearCategory::Grinder => "category = 'grinder'",
@ -80,7 +65,7 @@ impl GearRepository for SqlGearRepository {
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
Self::to_domain(record) record.try_into()
} }
async fn get(&self, id: GearId) -> Result<Gear, RepositoryError> { async fn get(&self, id: GearId) -> Result<Gear, RepositoryError> {
@ -97,7 +82,7 @@ impl GearRepository for SqlGearRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Self::to_domain(record) record.try_into()
} }
async fn list( async fn list(
@ -134,7 +119,7 @@ impl GearRepository for SqlGearRepository {
&count_query, &count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
Self::to_domain, |record: GearRecord| record.try_into(),
) )
.await .await
} }
@ -159,7 +144,7 @@ impl GearRepository for SqlGearRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.ok_or(RepositoryError::NotFound)?; .ok_or(RepositoryError::NotFound)?;
Self::to_domain(record) record.try_into()
} }
async fn delete(&self, id: GearId) -> Result<(), RepositoryError> { async fn delete(&self, id: GearId) -> Result<(), RepositoryError> {
@ -188,3 +173,22 @@ struct GearRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
} }
impl TryFrom<GearRecord> for Gear {
type Error = RepositoryError;
fn try_from(record: GearRecord) -> Result<Self, Self::Error> {
let category = GearCategory::from_str(&record.category).map_err(|()| {
RepositoryError::unexpected(format!("invalid category: {}", record.category))
})?;
Ok(Gear {
id: GearId::new(record.id),
category,
make: record.make,
model: record.model,
created_at: record.created_at,
updated_at: record.updated_at,
})
}
}

View file

@ -33,28 +33,6 @@ impl SqlRoasterRepository {
RoasterSortKey::City => format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC"), RoasterSortKey::City => format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC"),
} }
} }
fn into_domain(record: RoasterRecord) -> Roaster {
let RoasterRecord {
id,
name,
slug,
country,
city,
homepage,
created_at,
} = record;
Roaster {
id: RoasterId::from(id),
name,
slug,
country,
city,
homepage,
created_at,
}
}
} }
#[async_trait] #[async_trait]
@ -87,7 +65,7 @@ impl RoasterRepository for SqlRoasterRepository {
RepositoryError::unexpected(err.to_string()) RepositoryError::unexpected(err.to_string())
})?; })?;
Ok(Self::into_domain(record)) Ok(record.into())
} }
async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> { async fn get(&self, id: RoasterId) -> Result<Roaster, RepositoryError> {
@ -100,7 +78,7 @@ impl RoasterRepository for SqlRoasterRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Ok(Self::into_domain(record)), Some(record) => Ok(record.into()),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -115,7 +93,7 @@ impl RoasterRepository for SqlRoasterRepository {
.map_err(|err| RepositoryError::unexpected(err.to_string()))?; .map_err(|err| RepositoryError::unexpected(err.to_string()))?;
match record { match record {
Some(record) => Ok(Self::into_domain(record)), Some(record) => Ok(record.into()),
None => Err(RepositoryError::NotFound), None => Err(RepositoryError::NotFound),
} }
} }
@ -140,7 +118,7 @@ impl RoasterRepository for SqlRoasterRepository {
count_query, count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
|record| Ok(Self::into_domain(record)), |record: RoasterRecord| Ok(record.into()),
) )
.await .await
} }
@ -206,3 +184,17 @@ struct RoasterRecord {
homepage: Option<String>, homepage: Option<String>,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
impl From<RoasterRecord> for Roaster {
fn from(record: RoasterRecord) -> Self {
Roaster {
id: RoasterId::from(record.id),
name: record.name,
slug: record.slug,
country: record.country,
city: record.city,
homepage: record.homepage,
created_at: record.created_at,
}
}
}

View file

@ -104,7 +104,7 @@ impl RoastRepository for SqlRoastRepository {
map_insert_error(err, "unknown roaster reference") map_insert_error(err, "unknown roaster reference")
})?; })?;
record.into_roast() record.try_into()
} }
async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> { async fn get(&self, id: RoastId) -> Result<Roast, RepositoryError> {
@ -115,7 +115,7 @@ impl RoastRepository for SqlRoastRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(RoastRecord::into_roast) .map(Roast::try_from)
.transpose()? .transpose()?
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
@ -131,7 +131,7 @@ impl RoastRepository for SqlRoastRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(RoastWithRoasterRecord::into_with_roaster) .map(RoastWithRoaster::try_from)
.transpose()? .transpose()?
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
@ -149,7 +149,7 @@ impl RoastRepository for SqlRoastRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|err| RepositoryError::unexpected(err.to_string()))? .map_err(|err| RepositoryError::unexpected(err.to_string()))?
.map(RoastRecord::into_roast) .map(Roast::try_from)
.transpose()? .transpose()?
.ok_or(RepositoryError::NotFound) .ok_or(RepositoryError::NotFound)
} }
@ -184,7 +184,7 @@ impl RoastRepository for SqlRoastRepository {
count_query, count_query,
&order_clause, &order_clause,
sf.as_ref(), sf.as_ref(),
|record: RoastWithRoasterRecord| record.into_with_roaster(), |record: RoastWithRoasterRecord| record.try_into(),
) )
.await .await
} }
@ -203,7 +203,7 @@ impl RoastRepository for SqlRoastRepository {
records records
.into_iter() .into_iter()
.map(RoastWithRoasterRecord::into_with_roaster) .map(RoastWithRoaster::try_from)
.collect() .collect()
} }
@ -307,9 +307,11 @@ struct RoastRecord {
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
impl RoastRecord { impl TryFrom<RoastRecord> for Roast {
fn into_roast(self) -> Result<Roast, RepositoryError> { type Error = RepositoryError;
let tasting_notes = match self.tasting_notes {
fn try_from(record: RoastRecord) -> Result<Self, Self::Error> {
let tasting_notes = match record.tasting_notes {
Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| { Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
})?, })?,
@ -317,16 +319,16 @@ impl RoastRecord {
}; };
Ok(Roast { Ok(Roast {
id: RoastId::from(self.id), id: RoastId::from(record.id),
roaster_id: RoasterId::from(self.roaster_id), roaster_id: RoasterId::from(record.roaster_id),
name: self.name, name: record.name,
slug: self.slug, slug: record.slug,
origin: self.origin, origin: record.origin,
region: self.region, region: record.region,
producer: self.producer, producer: record.producer,
process: self.process, process: record.process,
tasting_notes, tasting_notes,
created_at: self.created_at, created_at: record.created_at,
}) })
} }
} }
@ -347,9 +349,11 @@ struct RoastWithRoasterRecord {
roaster_slug: String, roaster_slug: String,
} }
impl RoastWithRoasterRecord { impl TryFrom<RoastWithRoasterRecord> for RoastWithRoaster {
fn into_with_roaster(self) -> Result<RoastWithRoaster, RepositoryError> { type Error = RepositoryError;
let tasting_notes = match self.tasting_notes {
fn try_from(record: RoastWithRoasterRecord) -> Result<Self, Self::Error> {
let tasting_notes = match record.tasting_notes {
Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| { Some(raw) => from_str::<Vec<String>>(&raw).map_err(|err| {
RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) RepositoryError::unexpected(format!("failed to decode tasting notes: {err}"))
})?, })?,
@ -358,19 +362,19 @@ impl RoastWithRoasterRecord {
Ok(RoastWithRoaster { Ok(RoastWithRoaster {
roast: Roast { roast: Roast {
id: RoastId::from(self.id), id: RoastId::from(record.id),
roaster_id: RoasterId::from(self.roaster_id), roaster_id: RoasterId::from(record.roaster_id),
name: self.name, name: record.name,
slug: self.slug, slug: record.slug,
origin: self.origin, origin: record.origin,
region: self.region, region: record.region,
producer: self.producer, producer: record.producer,
process: self.process, process: record.process,
tasting_notes, tasting_notes,
created_at: self.created_at, created_at: record.created_at,
}, },
roaster_name: self.roaster_name, roaster_name: record.roaster_name,
roaster_slug: self.roaster_slug, roaster_slug: record.roaster_slug,
}) })
} }
} }