use std::path::Path;
use crate::generators::migration::run_migration;
use crate::generators::naming::{apply, pascal_to_snake, to_pascal_case, write_file};
use crate::patchers::lib_rs::patch_infra_lib;
pub(crate) const INFRA_REPOSITORY: &str = r#"use std::sync::Arc;
use async_trait::async_trait;
use uuid::Uuid;
use business::domain::{snake}::{
errors::{Pascal}Error,
model::{Pascal},
repository::{Pascal}RepositoryTrait,
};
use business::domain::logger::LoggerTrait;
pub struct InMemory{Pascal}Repository {
logger: Arc<dyn LoggerTrait>,
}
impl InMemory{Pascal}Repository {
pub fn new(logger: Arc<dyn LoggerTrait>) -> Self {
Self { logger }
}
}
#[async_trait]
impl {Pascal}RepositoryTrait for InMemory{Pascal}Repository {
async fn find_by_id(&self, id: Uuid) -> Result<Option<{Pascal}>, {Pascal}Error> {
self.logger.debug(&format!("find_by_id: {id}"));
Ok(None)
}
async fn save(&self, entity: &{Pascal}) -> Result<(), {Pascal}Error> {
self.logger.debug(&format!("save: {}", entity.id));
Ok(())
}
}
"#;
pub(crate) const INFRA_DB_REPOSITORY: &str = r#"use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use business::domain::{snake}::{
errors::{Pascal}Error,
model::{Pascal},
repository::{Pascal}RepositoryTrait,
};
use business::domain::logger::LoggerTrait;
use super::entity::{Pascal}Db;
pub struct Pg{Pascal}Repository {
pub pool: PgPool,
logger: Arc<dyn LoggerTrait>,
}
impl Pg{Pascal}Repository {
pub fn new(pool: PgPool, logger: Arc<dyn LoggerTrait>) -> Self {
Self { pool, logger }
}
}
#[async_trait]
impl {Pascal}RepositoryTrait for Pg{Pascal}Repository {
async fn find_by_id(&self, id: Uuid) -> Result<Option<{Pascal}>, {Pascal}Error> {
self.logger.debug(&format!("find_by_id: {id}"));
let row = sqlx::query_as!(
{Pascal}Db,
"SELECT id, created_at, updated_at, deleted, deleted_at, name
FROM {snake}s WHERE id = $1 AND deleted = false",
id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| {
self.logger.error(&format!("find_by_id error: {e}"));
{Pascal}Error::RepositoryError
})?;
row.map(|r| r.try_into()).transpose()
}
async fn save(&self, entity: &{Pascal}) -> Result<(), {Pascal}Error> {
self.logger.debug(&format!("save: {}", entity.id));
let db = {Pascal}Db::from(entity);
sqlx::query!(
"INSERT INTO {snake}s (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| {
if let Some(db_err) = e.as_database_error() {
if db_err.code().map_or(false, |c| c == "23505") {
return {Pascal}Error::Duplicate;
}
}
self.logger.error(&format!("save error: {e}"));
{Pascal}Error::RepositoryError
})?;
Ok(())
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use business::domain::{snake}::model::{Pascal}Props;
use std::sync::Arc;
struct TestLogger;
impl business::domain::logger::LoggerTrait for TestLogger {
fn info(&self, _: &str) {}
fn warn(&self, _: &str) {}
fn error(&self, _: &str) {}
fn debug(&self, _: &str) {}
}
fn test_repo(pool: PgPool) -> Pg{Pascal}Repository {
Pg{Pascal}Repository::new(pool, Arc::new(TestLogger))
}
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(Uuid::new_v4()).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");
}
}
"#;
pub(crate) const INFRA_ENTITY: &str = r#"use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use business::domain::{snake}::{errors::{Pascal}Error, model::{Pascal}};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct {Pascal}Db {
pub id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted: bool,
pub deleted_at: Option<DateTime<Utc>>,
pub name: String,
}
impl TryFrom<{Pascal}Db> for {Pascal} {
type Error = {Pascal}Error;
fn try_from(row: {Pascal}Db) -> Result<Self, Self::Error> {
Ok(Self::from_repository({Pascal} {
id: row.id,
created_at: row.created_at,
updated_at: row.updated_at,
deleted: row.deleted,
deleted_at: row.deleted_at,
name: row.name,
}))
}
}
impl From<&{Pascal}> for {Pascal}Db {
fn from(entity: &{Pascal}) -> Self {
Self {
id: entity.id,
created_at: entity.created_at,
updated_at: entity.updated_at,
deleted: entity.deleted,
deleted_at: entity.deleted_at,
name: entity.name.clone(),
}
}
}
"#;
const CRUD_INFRA_REPOSITORY: &str = r#"use std::sync::Arc;
use async_trait::async_trait;
use uuid::Uuid;
use business::domain::{snake}::{
errors::{Pascal}Error,
model::{Pascal},
repository::{Pascal}RepositoryTrait,
};
use business::domain::logger::LoggerTrait;
pub struct InMemory{Pascal}Repository {
logger: Arc<dyn LoggerTrait>,
}
impl InMemory{Pascal}Repository {
pub fn new(logger: Arc<dyn LoggerTrait>) -> Self {
Self { logger }
}
}
#[async_trait]
impl {Pascal}RepositoryTrait for InMemory{Pascal}Repository {
async fn find_all(&self) -> Result<Vec<{Pascal}>, {Pascal}Error> {
self.logger.debug("find_all");
Ok(vec![])
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<{Pascal}>, {Pascal}Error> {
self.logger.debug(&format!("find_by_id: {id}"));
Ok(None)
}
async fn save(&self, entity: &{Pascal}) -> Result<(), {Pascal}Error> {
self.logger.debug(&format!("save: {}", entity.id));
Ok(())
}
}
"#;
const CRUD_INFRA_DB_REPOSITORY: &str = r#"use std::sync::Arc;
use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use business::domain::{snake}::{
errors::{Pascal}Error,
model::{Pascal},
repository::{Pascal}RepositoryTrait,
};
use business::domain::logger::LoggerTrait;
use super::entity::{Pascal}Db;
pub struct Pg{Pascal}Repository {
pub pool: PgPool,
logger: Arc<dyn LoggerTrait>,
}
impl Pg{Pascal}Repository {
pub fn new(pool: PgPool, logger: Arc<dyn LoggerTrait>) -> Self {
Self { pool, logger }
}
}
#[async_trait]
impl {Pascal}RepositoryTrait for Pg{Pascal}Repository {
async fn find_all(&self) -> Result<Vec<{Pascal}>, {Pascal}Error> {
self.logger.debug("find_all");
let rows = sqlx::query_as!(
{Pascal}Db,
"SELECT id, created_at, updated_at, deleted, deleted_at, name
FROM {snake}s WHERE deleted = false ORDER BY created_at DESC"
)
.fetch_all(&self.pool)
.await
.map_err(|e| {
self.logger.error(&format!("find_all error: {e}"));
{Pascal}Error::RepositoryError
})?;
rows.into_iter().map(|r| r.try_into()).collect()
}
async fn find_by_id(&self, id: Uuid) -> Result<Option<{Pascal}>, {Pascal}Error> {
self.logger.debug(&format!("find_by_id: {id}"));
let row = sqlx::query_as!(
{Pascal}Db,
"SELECT id, created_at, updated_at, deleted, deleted_at, name
FROM {snake}s WHERE id = $1 AND deleted = false",
id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| {
self.logger.error(&format!("find_by_id error: {e}"));
{Pascal}Error::RepositoryError
})?;
row.map(|r| r.try_into()).transpose()
}
async fn save(&self, entity: &{Pascal}) -> Result<(), {Pascal}Error> {
self.logger.debug(&format!("save: {}", entity.id));
let db = {Pascal}Db::from(entity);
sqlx::query!(
"INSERT INTO {snake}s (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| {
if let Some(db_err) = e.as_database_error() {
if db_err.code().map_or(false, |c| c == "23505") {
return {Pascal}Error::Duplicate;
}
}
self.logger.error(&format!("save error: {e}"));
{Pascal}Error::RepositoryError
})?;
Ok(())
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use business::domain::{snake}::model::{Pascal}Props;
use std::sync::Arc;
struct TestLogger;
impl business::domain::logger::LoggerTrait for TestLogger {
fn info(&self, _: &str) {}
fn warn(&self, _: &str) {}
fn error(&self, _: &str) {}
fn debug(&self, _: &str) {}
}
fn test_repo(pool: PgPool) -> Pg{Pascal}Repository {
Pg{Pascal}Repository::new(pool, Arc::new(TestLogger))
}
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(Uuid::new_v4()).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");
}
#[sqlx::test(migrations = "./migrations")]
async fn should_list_all_{snake}s_excluding_deleted(pool: PgPool) {
// Arrange
seed(&pool, "first").await;
seed(&pool, "second").await;
// Act
let results = test_repo(pool).find_all().await.unwrap();
// Assert
assert_eq!(results.len(), 2);
}
}
"#;
pub const DB_RS: &str = r#"use sqlx::{PgPool, postgres::PgPoolOptions};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DatabaseError {
#[error("database.connection_error")]
ConnectionError,
#[error("database.migration_error")]
MigrationError,
}
pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(30))
.connect(database_url)
.await
.map_err(|_| DatabaseError::ConnectionError)
}
pub async fn run_migrations(pool: &PgPool) -> Result<(), DatabaseError> {
sqlx::migrate!("./migrations")
.run(pool)
.await
.map_err(|_| DatabaseError::MigrationError)
}
"#;
pub fn create_table_sql(snake: &str) -> String {
format!(
"CREATE TABLE {snake}s (\n id UUID PRIMARY KEY,\n name TEXT NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n deleted BOOLEAN NOT NULL DEFAULT FALSE,\n deleted_at TIMESTAMPTZ\n);\n"
)
}
pub fn write_repository_files(
pascal: &str,
snake: &str,
base: &Path,
db: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if db {
write_file(
&base.join(format!("infrastructure/src/{snake}/entity.rs")),
&apply(INFRA_ENTITY, pascal, snake),
)?;
write_file(
&base.join(format!("infrastructure/src/{snake}/repository.rs")),
&apply(CRUD_INFRA_DB_REPOSITORY, pascal, snake),
)?;
} else {
write_file(
&base.join(format!("infrastructure/src/{snake}/repository.rs")),
&apply(CRUD_INFRA_REPOSITORY, pascal, snake),
)?;
}
Ok(())
}
pub fn run_generate_repository(
name: &str,
base: &Path,
sqlx_bin: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let config = crate::puerto_toml::read(base)?;
let pascal = to_pascal_case(name);
let snake = pascal_to_snake(&pascal);
if !config.entity.iter().any(|e| e.name == pascal) {
return Err(format!(
"{pascal} not found in puerto.toml. Run `puerto generate domain {pascal}` first."
)
.into());
}
let db = config.project.db;
write_repository_files(&pascal, &snake, base, db)?;
patch_infra_lib(base, &snake, db)?;
if db {
run_migration(
&format!("create_{snake}_table"),
base,
sqlx_bin,
Some(&create_table_sql(&snake)),
)?;
}
let repo_label = if db {
format!("Pg{pascal}Repository")
} else {
format!("InMemory{pascal}Repository")
};
println!("✓ infrastructure/ — {repo_label}");
println!();
println!(" Next: puerto generate presentation {pascal}");
Ok(())
}