2026-05-05 18:27:36 +08:00
|
|
|
use std::fmt;
|
|
|
|
|
use std::str::FromStr;
|
|
|
|
|
|
|
|
|
|
use crate::DataLayerError;
|
|
|
|
|
|
2026-07-15 23:47:19 +08:00
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
|
|
|
pub struct PostgresPoolConfig {
|
|
|
|
|
pub database_url: String,
|
|
|
|
|
pub min_connections: u32,
|
|
|
|
|
pub max_connections: u32,
|
|
|
|
|
pub acquire_timeout_ms: u64,
|
|
|
|
|
pub idle_timeout_ms: u64,
|
|
|
|
|
pub max_lifetime_ms: u64,
|
|
|
|
|
pub statement_cache_capacity: usize,
|
|
|
|
|
pub require_ssl: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for PostgresPoolConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
database_url: String::new(),
|
|
|
|
|
min_connections: 4,
|
|
|
|
|
max_connections: 20,
|
|
|
|
|
acquire_timeout_ms: 10_000,
|
|
|
|
|
idle_timeout_ms: 30_000,
|
|
|
|
|
max_lifetime_ms: 30 * 60_000,
|
|
|
|
|
statement_cache_capacity: 100,
|
|
|
|
|
require_ssl: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PostgresPoolConfig {
|
|
|
|
|
pub fn validate(&self) -> Result<(), DataLayerError> {
|
|
|
|
|
if self.database_url.trim().is_empty() {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres database_url cannot be empty".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-09-07 00:09:42 +08:00
|
|
|
validate_database_url(&self.database_url)?;
|
2026-07-15 23:47:19 +08:00
|
|
|
if self.min_connections > self.max_connections {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres min_connections cannot exceed max_connections".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if self.statement_cache_capacity == 0 {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(
|
|
|
|
|
"postgres statement_cache_capacity must be positive".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 18:27:36 +08:00
|
|
|
#[derive(
|
|
|
|
|
Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord,
|
|
|
|
|
)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
pub enum DatabaseDriver {
|
|
|
|
|
Postgres,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DatabaseDriver {
|
|
|
|
|
pub const fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Postgres => "postgres",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn from_database_url(url: &str) -> Option<Self> {
|
|
|
|
|
let scheme = url.split_once(':')?.0.to_ascii_lowercase();
|
|
|
|
|
match scheme.as_str() {
|
|
|
|
|
"postgres" | "postgresql" => Some(Self::Postgres),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for DatabaseDriver {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
f.write_str(self.as_str())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl FromStr for DatabaseDriver {
|
|
|
|
|
type Err = DataLayerError;
|
|
|
|
|
|
|
|
|
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
|
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"postgres" | "postgresql" => Ok(Self::Postgres),
|
|
|
|
|
other => Err(DataLayerError::InvalidConfiguration(format!(
|
2026-09-07 00:09:42 +08:00
|
|
|
"unsupported database driver '{other}'; expected postgres or postgresql"
|
2026-05-05 18:27:36 +08:00
|
|
|
))),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 00:09:42 +08:00
|
|
|
fn validate_database_url(url: &str) -> Result<(), DataLayerError> {
|
|
|
|
|
if DatabaseDriver::from_database_url(url) != Some(DatabaseDriver::Postgres) {
|
|
|
|
|
let scheme = url
|
|
|
|
|
.split_once(':')
|
|
|
|
|
.map(|(scheme, _)| scheme)
|
|
|
|
|
.unwrap_or("missing");
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(format!(
|
|
|
|
|
"unsupported database URL scheme '{scheme}'; expected postgres or postgresql"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 18:27:36 +08:00
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
|
|
|
pub struct SqlPoolConfig {
|
|
|
|
|
pub min_connections: u32,
|
|
|
|
|
pub max_connections: u32,
|
|
|
|
|
pub acquire_timeout_ms: u64,
|
|
|
|
|
pub idle_timeout_ms: u64,
|
|
|
|
|
pub max_lifetime_ms: u64,
|
|
|
|
|
pub statement_cache_capacity: usize,
|
|
|
|
|
pub require_ssl: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for SqlPoolConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
min_connections: 1,
|
|
|
|
|
max_connections: 20,
|
|
|
|
|
acquire_timeout_ms: 10_000,
|
|
|
|
|
idle_timeout_ms: 30_000,
|
|
|
|
|
max_lifetime_ms: 30 * 60_000,
|
|
|
|
|
statement_cache_capacity: 100,
|
|
|
|
|
require_ssl: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SqlPoolConfig {
|
|
|
|
|
pub fn validate(&self, driver: DatabaseDriver) -> Result<(), DataLayerError> {
|
|
|
|
|
if self.min_connections > self.max_connections {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(format!(
|
|
|
|
|
"{driver} min_connections cannot exceed max_connections"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
if self.statement_cache_capacity == 0 {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(format!(
|
|
|
|
|
"{driver} statement_cache_capacity must be positive"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
|
|
|
|
pub struct SqlDatabaseConfig {
|
|
|
|
|
pub driver: DatabaseDriver,
|
|
|
|
|
pub url: String,
|
|
|
|
|
pub pool: SqlPoolConfig,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SqlDatabaseConfig {
|
|
|
|
|
pub fn new(
|
|
|
|
|
driver: DatabaseDriver,
|
|
|
|
|
url: impl Into<String>,
|
|
|
|
|
pool: SqlPoolConfig,
|
|
|
|
|
) -> Result<Self, DataLayerError> {
|
|
|
|
|
let config = Self {
|
|
|
|
|
driver,
|
|
|
|
|
url: url.into(),
|
|
|
|
|
pool,
|
|
|
|
|
};
|
|
|
|
|
config.validate()?;
|
|
|
|
|
Ok(config)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn validate(&self) -> Result<(), DataLayerError> {
|
|
|
|
|
if self.url.trim().is_empty() {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(format!(
|
|
|
|
|
"{} database url cannot be empty",
|
|
|
|
|
self.driver
|
|
|
|
|
)));
|
|
|
|
|
}
|
2026-09-07 00:09:42 +08:00
|
|
|
validate_database_url(&self.url)?;
|
2026-05-05 18:27:36 +08:00
|
|
|
self.pool.validate(self.driver)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn from_postgres_config(postgres: PostgresPoolConfig) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
driver: DatabaseDriver::Postgres,
|
|
|
|
|
url: postgres.database_url,
|
|
|
|
|
pool: SqlPoolConfig {
|
|
|
|
|
min_connections: postgres.min_connections,
|
|
|
|
|
max_connections: postgres.max_connections,
|
|
|
|
|
acquire_timeout_ms: postgres.acquire_timeout_ms,
|
|
|
|
|
idle_timeout_ms: postgres.idle_timeout_ms,
|
|
|
|
|
max_lifetime_ms: postgres.max_lifetime_ms,
|
|
|
|
|
statement_cache_capacity: postgres.statement_cache_capacity,
|
|
|
|
|
require_ssl: postgres.require_ssl,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn to_postgres_config(&self) -> Result<PostgresPoolConfig, DataLayerError> {
|
|
|
|
|
if self.driver != DatabaseDriver::Postgres {
|
|
|
|
|
return Err(DataLayerError::InvalidConfiguration(format!(
|
|
|
|
|
"cannot build postgres pool config from {} database config",
|
|
|
|
|
self.driver
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(PostgresPoolConfig {
|
|
|
|
|
database_url: self.url.clone(),
|
|
|
|
|
min_connections: self.pool.min_connections,
|
|
|
|
|
max_connections: self.pool.max_connections,
|
|
|
|
|
acquire_timeout_ms: self.pool.acquire_timeout_ms,
|
|
|
|
|
idle_timeout_ms: self.pool.idle_timeout_ms,
|
|
|
|
|
max_lifetime_ms: self.pool.max_lifetime_ms,
|
|
|
|
|
statement_cache_capacity: self.pool.statement_cache_capacity,
|
|
|
|
|
require_ssl: self.pool.require_ssl,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<PostgresPoolConfig> for SqlDatabaseConfig {
|
|
|
|
|
fn from(value: PostgresPoolConfig) -> Self {
|
|
|
|
|
Self::from_postgres_config(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-09-07 00:09:42 +08:00
|
|
|
use super::{DatabaseDriver, PostgresPoolConfig, SqlDatabaseConfig, SqlPoolConfig};
|
2026-05-05 18:27:36 +08:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_database_driver_aliases() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
"postgresql".parse::<DatabaseDriver>().unwrap(),
|
|
|
|
|
DatabaseDriver::Postgres
|
|
|
|
|
);
|
|
|
|
|
assert!("oracle".parse::<DatabaseDriver>().is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn infers_driver_from_database_url_scheme() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
DatabaseDriver::from_database_url("postgres://localhost/aether"),
|
|
|
|
|
Some(DatabaseDriver::Postgres)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn validates_driver_url_mismatch() {
|
|
|
|
|
let config = SqlDatabaseConfig {
|
2026-09-07 00:09:42 +08:00
|
|
|
driver: DatabaseDriver::Postgres,
|
|
|
|
|
url: "unsupported://localhost/aether".to_string(),
|
2026-05-05 18:27:36 +08:00
|
|
|
pool: SqlPoolConfig::default(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
assert!(config.validate().is_err());
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-07 00:09:42 +08:00
|
|
|
#[test]
|
|
|
|
|
fn rejects_removed_database_drivers_and_url_schemes() {
|
|
|
|
|
for unsupported in ["mysql", "mariadb", "sqlite"] {
|
|
|
|
|
assert!(unsupported.parse::<DatabaseDriver>().is_err());
|
|
|
|
|
assert!(serde_json::from_str::<DatabaseDriver>(&format!("\"{unsupported}\"")).is_err());
|
|
|
|
|
let url = format!("{unsupported}://localhost/aether");
|
|
|
|
|
assert_eq!(DatabaseDriver::from_database_url(&url), None);
|
|
|
|
|
{
|
|
|
|
|
let driver = DatabaseDriver::Postgres;
|
|
|
|
|
let error = SqlDatabaseConfig::new(driver, &url, SqlPoolConfig::default())
|
|
|
|
|
.expect_err("unsupported URL must not fall back to another driver");
|
|
|
|
|
assert!(error
|
|
|
|
|
.to_string()
|
|
|
|
|
.contains("unsupported database URL scheme"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 18:27:36 +08:00
|
|
|
#[test]
|
|
|
|
|
fn builds_legacy_postgres_config_round_trip() {
|
|
|
|
|
let postgres = PostgresPoolConfig {
|
|
|
|
|
database_url: "postgres://localhost/aether".to_string(),
|
|
|
|
|
min_connections: 2,
|
|
|
|
|
max_connections: 8,
|
|
|
|
|
acquire_timeout_ms: 1_500,
|
|
|
|
|
idle_timeout_ms: 5_000,
|
|
|
|
|
max_lifetime_ms: 30_000,
|
|
|
|
|
statement_cache_capacity: 64,
|
|
|
|
|
require_ssl: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let database = SqlDatabaseConfig::from_postgres_config(postgres.clone());
|
|
|
|
|
|
|
|
|
|
assert_eq!(database.driver, DatabaseDriver::Postgres);
|
|
|
|
|
assert_eq!(database.to_postgres_config().unwrap(), postgres);
|
|
|
|
|
}
|
|
|
|
|
}
|