puerto 0.19.0

Puerto — Rust full-stack DDD framework CLI. Scaffolds Clean Architecture projects with auto-wired DI, SQLx support, and zero manual wiring. Formerly puerto-framework.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;

use business::domain::user::{
    model::{User, UserId},
    repository::UserRepositoryTrait,
};
use puerto_core::RepositoryError;

use crate::db::map_sqlx_error;

/// Runtime-checked queries (`query_as`, not `query_as!`): `puerto new` writes this file before
/// there is a database to build the query cache from, and the project builds offline.
pub struct PgUserRepository {
    pub pool: PgPool,
}

impl PgUserRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[derive(sqlx::FromRow)]
struct UserRow {
    id: Uuid,
    issuer: String,
    subject: String,
    email: Option<String>,
    name: Option<String>,
    roles: Vec<String>,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
}

impl From<UserRow> for User {
    fn from(row: UserRow) -> Self {
        User::from_repository(User {
            id: UserId::from_uuid(row.id),
            issuer: row.issuer,
            subject: row.subject,
            email: row.email,
            name: row.name,
            roles: row.roles,
            created_at: row.created_at,
            updated_at: row.updated_at,
        })
    }
}

const COLUMNS: &str = "id, issuer, subject, email, name, roles, created_at, updated_at";

#[async_trait]
impl UserRepositoryTrait for PgUserRepository {
    async fn find_by_subject(
        &self,
        issuer: &str,
        subject: &str,
    ) -> Result<Option<User>, RepositoryError> {
        let row = sqlx::query_as::<_, UserRow>(&format!(
            "SELECT {COLUMNS} FROM users WHERE issuer = $1 AND subject = $2"
        ))
        .bind(issuer)
        .bind(subject)
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "find_by_subject failed");
            map_sqlx_error(&e)
        })?;
        Ok(row.map(User::from))
    }

    async fn find_by_id(&self, id: UserId) -> Result<Option<User>, RepositoryError> {
        tracing::debug!(%id, "find_by_id");
        let row = sqlx::query_as::<_, UserRow>(&format!("SELECT {COLUMNS} FROM users WHERE id = $1"))
            .bind(id.value())
            .fetch_optional(&self.pool)
            .await
            .map_err(|e| {
                tracing::error!(error = %e, "find_by_id failed");
                map_sqlx_error(&e)
            })?;
        Ok(row.map(User::from))
    }

    async fn save(&self, user: &User) -> Result<(), RepositoryError> {
        tracing::debug!(id = %user.id, "save");
        sqlx::query(
            "INSERT INTO users (id, issuer, subject, email, name, roles, created_at, updated_at)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
             ON CONFLICT (id) DO UPDATE SET
                 email = EXCLUDED.email,
                 name = EXCLUDED.name,
                 roles = EXCLUDED.roles,
                 updated_at = EXCLUDED.updated_at",
        )
        .bind(user.id.value())
        .bind(&user.issuer)
        .bind(&user.subject)
        .bind(&user.email)
        .bind(&user.name)
        .bind(&user.roles)
        .bind(user.created_at)
        .bind(user.updated_at)
        .execute(&self.pool)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "save failed");
            map_sqlx_error(&e)
        })?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ada() -> User {
        User::provision("issuer", "ada", Some("ada@example.com".into()), None)
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn should_find_a_saved_user_when_looked_up_by_subject_or_id(pool: PgPool) {
        // Arrange
        let repository = PgUserRepository::new(pool);
        let user = ada();
        repository.save(&user).await.expect("saved");

        // Act
        let by_subject = repository
            .find_by_subject("issuer", "ada")
            .await
            .expect("read");
        let by_id = repository.find_by_id(user.id).await.expect("read");

        // Assert
        assert_eq!(by_subject.map(|u| u.id), Some(user.id));
        assert_eq!(by_id.map(|u| u.subject), Some("ada".to_string()));
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn should_reject_a_second_user_when_the_subject_is_taken(pool: PgPool) {
        // Arrange
        let repository = PgUserRepository::new(pool);
        repository.save(&ada()).await.expect("saved");

        // Act
        let result = repository.save(&ada()).await;

        // Assert
        assert_eq!(result, Err(RepositoryError::Duplicate));
    }
}