feat: add postgres migrations and repository adapters for vertical slice

This commit is contained in:
2026-05-31 05:43:21 +02:00
parent 4e2fc99065
commit 8c1a0e4519
15 changed files with 1324 additions and 39 deletions

View File

@@ -1,11 +1,34 @@
use crate::db::PgPool;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
entities::User,
errors::DomainError,
ports::UserRepository,
value_objects::{Email, PasswordHash, SystemId},
};
use uuid::Uuid;
#[derive(sqlx::FromRow)]
struct UserRow {
id: Uuid,
username: String,
email: String,
password_hash: String,
created_at: DateTime<Utc>,
}
impl TryFrom<UserRow> for domain::entities::User {
type Error = DomainError;
fn try_from(r: UserRow) -> Result<Self, Self::Error> {
Ok(Self {
id: SystemId::from_uuid(r.id),
username: r.username,
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
created_at: r.created_at,
})
}
}
pub struct PostgresUserRepository {
pool: PgPool,
@@ -19,66 +42,66 @@ impl PostgresUserRepository {
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<User>, DomainError> {
let row = sqlx::query!(
"SELECT id, email, password_hash, created_at FROM users WHERE id = $1",
*id.as_uuid()
async fn find_by_id(&self, id: &SystemId) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
)
.bind(*id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| {
Ok(User {
id: SystemId::from_uuid(r.id),
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
created_at: r.created_at,
})
})
.transpose()
row.map(TryInto::try_into).transpose()
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let row = sqlx::query!(
"SELECT id, email, password_hash, created_at FROM users WHERE email = $1",
email.as_str()
async fn find_by_email(&self, email: &Email) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1",
)
.bind(email.as_str())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(|r| {
Ok(User {
id: SystemId::from_uuid(r.id),
email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash),
created_at: r.created_at,
})
})
.transpose()
row.map(TryInto::try_into).transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query!(
"INSERT INTO users (id, email, password_hash, created_at)
VALUES ($1, $2, $3, $4)
async fn find_by_username(&self, username: &str) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
)
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
row.map(TryInto::try_into).transpose()
}
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
sqlx::query_as::<_, UserRow>(
"INSERT INTO users (id, username, email, password_hash, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username,
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash",
*user.id.as_uuid(),
user.email.as_str(),
user.password_hash.as_str(),
user.created_at
password_hash = EXCLUDED.password_hash
RETURNING id, username, email, password_hash, created_at",
)
.execute(&self.pool)
.bind(*user.id.as_uuid())
.bind(&user.username)
.bind(user.email.as_str())
.bind(user.password_hash.as_str())
.bind(user.created_at)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query!("DELETE FROM users WHERE id = $1", *id.as_uuid())
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;