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));
}
}