pub mod events;
pub mod uow;
use puerto_core::RepositoryError;
use sqlx::migrate::Migrator;
use sqlx::postgres::{PgConnectOptions, PgConnection, PgPoolOptions};
use sqlx::{Connection, PgPool};
use std::time::Duration;
pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DatabaseError {
#[error("cannot connect to the database at {target}: {cause}")]
ConnectionError { target: String, cause: String },
#[error("cannot apply the database migrations: {cause}")]
MigrationError { cause: String },
}
pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
let failed = |e: sqlx::Error| DatabaseError::ConnectionError {
target: describe_url(database_url),
cause: e.to_string(),
};
let options: PgConnectOptions = database_url.parse().map_err(failed)?;
let probe = PgConnection::connect_with(&options).await.map_err(failed)?;
let _ = probe.close().await;
Ok(PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(30))
.connect_lazy_with(options))
}
fn describe_url(url: &str) -> String {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest);
rest.split('?').next().unwrap_or(rest).to_string()
}
pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
migrator
.run(pool)
.await
.map_err(|e| DatabaseError::MigrationError {
cause: e.to_string(),
})
}
pub async fn ping(pool: &PgPool) -> bool {
sqlx::query("SELECT 1").execute(pool).await.is_ok()
}
pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
let unique_violation = err
.as_database_error()
.and_then(|db_err| db_err.code())
.is_some_and(|code| code == "23505");
if unique_violation {
RepositoryError::Duplicate
} else {
RepositoryError::Unavailable
}
}
#[cfg(test)]
mod tests {
use super::*;
fn unreachable_pool() -> PgPool {
PgPoolOptions::new()
.acquire_timeout(Duration::from_millis(200))
.connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
.unwrap()
}
#[derive(Debug)]
struct FakeDbError(&'static str);
impl std::fmt::Display for FakeDbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SQLSTATE {}", self.0)
}
}
impl std::error::Error for FakeDbError {}
impl sqlx::error::DatabaseError for FakeDbError {
fn message(&self) -> &str {
self.0
}
fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
Some(self.0.into())
}
fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
self
}
fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
self
}
fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
self
}
fn kind(&self) -> sqlx::error::ErrorKind {
sqlx::error::ErrorKind::Other
}
}
fn database_error(code: &'static str) -> sqlx::Error {
sqlx::Error::Database(Box::new(FakeDbError(code)))
}
#[test]
fn should_map_a_unique_violation_to_duplicate() {
assert_eq!(
map_sqlx_error(&database_error("23505")),
RepositoryError::Duplicate
);
}
#[test]
fn should_map_another_database_error_to_unavailable() {
assert_eq!(
map_sqlx_error(&database_error("23503")),
RepositoryError::Unavailable
);
}
#[test]
fn should_map_a_non_database_error_to_unavailable() {
assert_eq!(
map_sqlx_error(&sqlx::Error::PoolTimedOut),
RepositoryError::Unavailable
);
}
#[tokio::test]
async fn should_report_not_ready_when_the_database_is_unreachable() {
assert!(!ping(&unreachable_pool()).await);
}
#[tokio::test]
async fn should_reject_a_database_url_that_is_not_one() {
let result = create_postgres_pool("not a database url").await;
assert!(matches!(result, Err(DatabaseError::ConnectionError { .. })));
}
#[tokio::test]
async fn should_say_where_and_why_the_connection_failed_without_the_password() {
let url = "postgres://devuser:s3cret@127.0.0.1:1/shop";
let started = std::time::Instant::now();
let message = create_postgres_pool(url).await.unwrap_err().to_string();
assert!(message.contains("127.0.0.1:1/shop"), "{message}");
assert!(message.to_lowercase().contains("refused"), "{message}");
assert!(!message.contains("s3cret"), "{message}");
assert!(!message.contains("devuser"), "{message}");
assert!(
started.elapsed() < Duration::from_secs(5),
"{:?}",
started.elapsed()
);
}
#[test]
fn should_describe_a_database_url_by_its_host_and_database_only() {
assert_eq!(
describe_url("postgres://u:p@db.internal:5432/shop?sslmode=require"),
"db.internal:5432/shop"
);
assert_eq!(describe_url("postgres://localhost/shop"), "localhost/shop");
assert_eq!(describe_url("not a database url"), "not a database url");
}
async fn test_database() -> Option<PgPool> {
let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
Some(
create_postgres_pool(&format!("{server}/postgres"))
.await
.unwrap(),
)
}
#[tokio::test]
#[ignore = "needs Postgres: make test/db"]
async fn postgres_unique_violation_maps_to_duplicate() {
let Some(pool) = test_database().await else {
eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
return;
};
let mut conn = pool.acquire().await.unwrap();
sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
.execute(&mut *conn)
.await
.unwrap();
sqlx::query("INSERT INTO skus VALUES ('A-1')")
.execute(&mut *conn)
.await
.unwrap();
let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
.execute(&mut *conn)
.await
.unwrap_err();
assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
}
#[tokio::test]
#[ignore = "needs Postgres: make test/db"]
async fn postgres_ping_reports_ready() {
let Some(pool) = test_database().await else {
eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
return;
};
assert!(ping(&pool).await);
}
}