use async_trait::async_trait;
use business::domain::{{ snake }}::{
model::{{ "{" }}{{ pascal }}, {{ pascal }}Id},
repository::{{ pascal }}RepositoryTrait,
};
use puerto_core::RepositoryError;
use sqlx::PgPool;
use super::entity::{{ pascal }}Db;
use crate::db::map_sqlx_error;
pub struct Pg{{ pascal }}Repository {
pub pool: PgPool,
}
impl Pg{{ pascal }}Repository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
/// A row as the domain's entity. A row the domain rejects is stored data gone bad, not the
/// caller's fault: it is logged with the domain's reason and reported as `InvalidData`.
fn to_domain(row: {{ pascal }}Db) -> Result<{{ pascal }}, RepositoryError> {
{{ pascal }}::try_from(row).map_err(|e| {
tracing::error!(error = %e, "stored row is not a valid {{ snake }}");
RepositoryError::InvalidData
})
}
#[async_trait]
impl {{ pascal }}RepositoryTrait for Pg{{ pascal }}Repository {
async fn find_by_id(&self, id: {{ pascal }}Id) -> Result<Option<{{ pascal }}>, RepositoryError> {
tracing::debug!(%id, "find_by_id");
let row = sqlx::query_as!(
{{ pascal }}Db,
"SELECT id, created_at, updated_at, deleted, deleted_at, name
FROM {{ plural }} WHERE id = $1 AND deleted = false",
id.value()
)
.fetch_optional(&self.pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "find_by_id failed");
map_sqlx_error(&e)
})?;
row.map(to_domain).transpose()
}
async fn save(&self, entity: &{{ pascal }}) -> Result<(), RepositoryError> {
tracing::debug!(id = %entity.id, "save");
let db = {{ pascal }}Db::from(entity);
sqlx::query!(
"INSERT INTO {{ plural }} (id, created_at, updated_at, deleted, deleted_at, name)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE
SET updated_at = $3, deleted = $4, deleted_at = $5, name = $6",
db.id,
db.created_at,
db.updated_at,
db.deleted,
db.deleted_at,
db.name
)
.execute(&self.pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "save failed");
map_sqlx_error(&e)
})?;
Ok(())
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use business::domain::{{ snake }}::model::{{ pascal }}Props;
fn test_repo(pool: PgPool) -> Pg{{ pascal }}Repository {
Pg{{ pascal }}Repository::new(pool)
}
async fn seed(pool: &PgPool, name: &str) -> {{ pascal }} {
let entity = {{ pascal }}::new({{ pascal }}Props { name: name.to_string() }).unwrap();
test_repo(pool.clone()).save(&entity).await.unwrap();
entity
}
#[sqlx::test(migrations = "./migrations")]
async fn should_persist_and_retrieve_by_id(pool: PgPool) {
// Arrange
let entity = seed(&pool, "example").await;
// Act
let found = test_repo(pool).find_by_id(entity.id).await.unwrap().unwrap();
// Assert
assert_eq!(found.id, entity.id);
assert_eq!(found.name, entity.name);
assert!(!found.deleted);
}
#[sqlx::test(migrations = "./migrations")]
async fn should_return_none_for_nonexistent_id(pool: PgPool) {
// Act
let result = test_repo(pool).find_by_id({{ pascal }}Id::generate()).await.unwrap();
// Assert
assert!(result.is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn should_update_entity_on_save_conflict(pool: PgPool) {
// Arrange
let mut entity = seed(&pool, "original").await;
entity.name = "updated".to_string();
entity.updated_at = chrono::Utc::now();
// Act
test_repo(pool.clone()).save(&entity).await.unwrap();
// Assert
let found = test_repo(pool).find_by_id(entity.id).await.unwrap().unwrap();
assert_eq!(found.name, "updated");
}
}