Files
Aether/crates/aether-data/contracts/src/database.rs
T
elky 2281f2b754 refactor(data): remove MySQL and SQLite support
Use PostgreSQL as the only database backend across runtime, schema tooling, installation, Compose, and CI. Update regression tests and reject removed drivers explicitly.
2026-09-07 00:09:42 +08:00

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);
}
}