use crate::PostgresError;
use crate::{TransactionManager, TransactionTrait};
use async_trait::async_trait;
use std::fmt::Debug;
use std::str::FromStr;
use std::sync::Arc;
use testkit_core::{
DatabaseBackend, DatabaseConfig, DatabaseName, DatabasePool, TestDatabaseConnection,
TestDatabaseInstance,
};
use url::Url;
#[derive(Clone)]
pub struct PostgresConnection {
client: Arc<deadpool_postgres::Client>,
connection_string: String,
}
impl PostgresConnection {
pub async fn connect(connection_string: impl Into<String>) -> Result<Self, PostgresError> {
let connection_string = connection_string.into();
let pg_config = tokio_postgres::config::Config::from_str(&connection_string)
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
let mgr_config = deadpool_postgres::ManagerConfig {
recycling_method: deadpool_postgres::RecyclingMethod::Fast,
};
let mgr =
deadpool_postgres::Manager::from_config(pg_config, tokio_postgres::NoTls, mgr_config);
let pool = deadpool_postgres::Pool::builder(mgr)
.max_size(1)
.build()
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
let client = pool
.get()
.await
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
Ok(Self {
client: Arc::new(client),
connection_string,
})
}
pub async fn with_connection<F, R, E>(
connection_string: impl Into<String>,
operation: F,
) -> Result<R, PostgresError>
where
F: FnOnce(&PostgresConnection) -> futures::future::BoxFuture<'_, Result<R, E>>,
E: std::error::Error + Send + Sync + 'static,
{
let conn = Self::connect(connection_string).await?;
let result = operation(&conn)
.await
.map_err(|e| PostgresError::QueryError(e.to_string()))?;
Ok(result)
}
pub fn client(&self) -> &deadpool_postgres::Client {
&self.client
}
}
impl TestDatabaseConnection for PostgresConnection {
fn connection_string(&self) -> String {
self.connection_string.clone()
}
}
#[derive(Clone)]
pub struct PostgresPool {
pool: Arc<deadpool_postgres::Pool>,
connection_string: String,
}
#[async_trait]
impl DatabasePool for PostgresPool {
type Connection = PostgresConnection;
type Error = PostgresError;
async fn acquire(&self) -> Result<Self::Connection, Self::Error> {
let client = self
.pool
.get()
.await
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
Ok(PostgresConnection {
client: Arc::new(client),
connection_string: self.connection_string.clone(),
})
}
async fn release(&self, _conn: Self::Connection) -> Result<(), Self::Error> {
Ok(())
}
fn connection_string(&self) -> String {
self.connection_string.clone()
}
}
#[derive(Clone, Debug)]
pub struct PostgresBackend {
config: DatabaseConfig,
}
#[async_trait]
impl DatabaseBackend for PostgresBackend {
type Connection = PostgresConnection;
type Pool = PostgresPool;
type Error = PostgresError;
async fn new(config: DatabaseConfig) -> Result<Self, Self::Error> {
if config.admin_url.is_empty() || config.user_url.is_empty() {
return Err(PostgresError::ConfigError(
"Admin and user URLs must be provided".into(),
));
}
Ok(Self { config })
}
async fn create_pool(
&self,
name: &DatabaseName,
_config: &DatabaseConfig,
) -> Result<Self::Pool, Self::Error> {
let connection_string = self.connection_string(name);
let pg_config = tokio_postgres::config::Config::from_str(&connection_string)
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
let mgr_config = deadpool_postgres::ManagerConfig {
recycling_method: deadpool_postgres::RecyclingMethod::Fast,
};
let mgr =
deadpool_postgres::Manager::from_config(pg_config, tokio_postgres::NoTls, mgr_config);
let pool = deadpool_postgres::Pool::builder(mgr)
.max_size(20)
.build()
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
Ok(PostgresPool {
pool: Arc::new(pool),
connection_string,
})
}
async fn connect(&self, name: &DatabaseName) -> Result<Self::Connection, Self::Error> {
let connection_string = self.connection_string(name);
PostgresConnection::connect(connection_string).await
}
async fn connect_with_string(
&self,
connection_string: &str,
) -> Result<Self::Connection, Self::Error> {
PostgresConnection::connect(connection_string).await
}
async fn create_database(
&self,
_pool: &Self::Pool,
name: &DatabaseName,
) -> Result<(), Self::Error> {
let _admin_config = tokio_postgres::config::Config::from_str(&self.config.admin_url)
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
let (client, connection) =
tokio_postgres::connect(&self.config.admin_url, tokio_postgres::NoTls)
.await
.map_err(|e| PostgresError::ConnectionError(e.to_string()))?;
tokio::spawn(async move { if let Err(_e) = connection.await {} });
let db_name = name.as_str();
let create_query = format!("CREATE DATABASE \"{}\"", db_name);
client
.execute(&create_query, &[])
.await
.map_err(|e| PostgresError::DatabaseCreationError(e.to_string()))?;
Ok(())
}
fn drop_database(&self, name: &DatabaseName) -> Result<(), Self::Error> {
let url = match Url::parse(&self.config.admin_url) {
Ok(url) => url,
Err(e) => {
tracing::error!("Failed to parse admin URL: {}", e);
return Err(PostgresError::ConfigError(e.to_string()));
}
};
let database_name = name.as_str();
let test_user = url.username();
let database_host = format!(
"{}://{}:{}@{}:{}",
url.scheme(),
test_user,
url.password().unwrap_or(""),
url.host_str().unwrap_or("postgres"),
url.port().unwrap_or(5432)
);
let output = std::process::Command::new("psql")
.arg(&database_host)
.arg("-c")
.arg(format!("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid();", database_name))
.output();
if let Err(e) = output {
tracing::warn!(
"Failed to terminate connections to database {}: {}",
database_name,
e
);
}
let output = std::process::Command::new("psql")
.arg(&database_host)
.arg("-c")
.arg(format!("DROP DATABASE IF EXISTS \"{}\";", database_name))
.output();
match output {
Ok(output) => {
if output.status.success() {
tracing::info!("Successfully dropped database {}", name);
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::error!("Failed to drop database {}: {}", name, stderr);
Err(PostgresError::DatabaseDropError(stderr.to_string()))
}
}
Err(e) => {
tracing::error!("Failed to execute psql command to drop {}: {}", name, e);
Err(PostgresError::DatabaseDropError(e.to_string()))
}
}
}
fn connection_string(&self, name: &DatabaseName) -> String {
let base_url = &self.config.user_url;
if let Some(db_pos) = base_url.rfind('/') {
let (prefix, _) = base_url.split_at(db_pos + 1);
return format!("{}{}", prefix, name.as_str());
}
format!("postgres://postgres/{}", name.as_str())
}
}
pub struct PostgresTransaction {
client: Arc<deadpool_postgres::Client>,
}
#[async_trait]
impl TransactionTrait for PostgresTransaction {
type Error = PostgresError;
async fn commit(&mut self) -> Result<(), Self::Error> {
self.client
.execute("COMMIT", &[])
.await
.map_err(|e| PostgresError::TransactionError(e.to_string()))?;
Ok(())
}
async fn rollback(&mut self) -> Result<(), Self::Error> {
self.client
.execute("ROLLBACK", &[])
.await
.map_err(|e| PostgresError::TransactionError(e.to_string()))?;
Ok(())
}
}
#[async_trait]
impl TransactionManager for TestDatabaseInstance<PostgresBackend> {
type Error = PostgresError;
type Tx = PostgresTransaction;
type Connection = PostgresConnection;
async fn begin_transaction(&mut self) -> Result<Self::Tx, Self::Error> {
let pool = &self.pool;
let client = pool.acquire().await?;
client
.client
.execute("BEGIN", &[])
.await
.map_err(|e| PostgresError::TransactionError(e.to_string()))?;
Ok(PostgresTransaction {
client: Arc::clone(&client.client),
})
}
async fn commit_transaction(tx: &mut Self::Tx) -> Result<(), Self::Error> {
tx.commit().await
}
async fn rollback_transaction(tx: &mut Self::Tx) -> Result<(), Self::Error> {
tx.rollback().await
}
}
pub async fn postgres_backend() -> Result<PostgresBackend, PostgresError> {
let config = DatabaseConfig::default();
PostgresBackend::new(config).await
}
pub async fn postgres_backend_with_config(
config: DatabaseConfig,
) -> Result<PostgresBackend, PostgresError> {
PostgresBackend::new(config).await
}