mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 01:17:46 +08:00
Use PostgreSQL as the only database backend across runtime, schema tooling, installation, Compose, and CI. Update regression tests and reject removed drivers explicitly.
296 lines
9.3 KiB
Rust
296 lines
9.3 KiB
Rust
use std::fmt;
|
|
use std::str::FromStr;
|
|
|
|
use crate::DataLayerError;
|
|
|
|
#[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(),
|
|
));
|
|
}
|
|
validate_database_url(&self.database_url)?;
|
|
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(())
|
|
}
|
|
}
|
|
|
|
#[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!(
|
|
"unsupported database driver '{other}'; expected postgres or postgresql"
|
|
))),
|
|
}
|
|
}
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
#[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
|
|
)));
|
|
}
|
|
validate_database_url(&self.url)?;
|
|
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 {
|
|
use super::{DatabaseDriver, PostgresPoolConfig, SqlDatabaseConfig, SqlPoolConfig};
|
|
|
|
#[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 {
|
|
driver: DatabaseDriver::Postgres,
|
|
url: "unsupported://localhost/aether".to_string(),
|
|
pool: SqlPoolConfig::default(),
|
|
};
|
|
|
|
assert!(config.validate().is_err());
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|