use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Missing required configuration: {0}")]
MissingField(String),
#[error("Missing required configuration: dbnexus.url")]
MissingUrl,
#[error("Invalid cache capacity: {0}")]
InvalidCacheCapacity(String),
#[error("Invalid configuration value for '{key}': {message}")]
InvalidValue {
key: String,
message: String,
},
#[error("Invalid configuration format: {0}")]
InvalidFormat(String),
#[error("Configuration file not found: {0}")]
FileNotFound(String),
#[error("IO error: {0}")]
IoError(String),
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Unsupported database protocol: {0}")]
UnsupportedProtocol(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Validation error: {0}")]
ValidationError(String),
}
impl crate::i18n::error_ext::LocalizedMsg for ConfigError {
fn message_key(&self) -> &'static str {
match self {
Self::MissingField(_) => "config-missing-field",
Self::MissingUrl => "config-missing-url",
Self::InvalidCacheCapacity(_) => "config-invalid-cache-capacity",
Self::InvalidValue { .. } => "config-invalid-value",
Self::InvalidFormat(_) => "config-invalid-format",
Self::FileNotFound(_) => "config-file-not-found",
Self::IoError(_) => "config-io-error",
Self::InvalidUrl(_) => "config-invalid-url",
Self::UnsupportedProtocol(_) => "config-unsupported-protocol",
Self::ParseError(_) => "config-parse-error",
Self::ValidationError(_) => "config-validation-error",
}
}
fn message_args(&self) -> Vec<(&str, String)> {
match self {
Self::MissingField(field) => vec![("field", field.clone())],
Self::InvalidCacheCapacity(reason) => vec![("reason", reason.clone())],
Self::InvalidValue { key, message } => vec![("key", key.clone()), ("message", message.clone())],
Self::InvalidFormat(reason) => vec![("reason", reason.clone())],
Self::FileNotFound(path) => vec![("path", path.clone())],
Self::IoError(reason) => vec![("reason", reason.clone())],
Self::InvalidUrl(url) => vec![("url", url.clone())],
Self::UnsupportedProtocol(protocol) => vec![("protocol", protocol.clone())],
Self::ParseError(reason) => vec![("reason", reason.clone())],
Self::ValidationError(reason) => vec![("reason", reason.clone())],
Self::MissingUrl => vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_policy_cache_capacity")]
pub policy_cache_capacity: u64,
#[serde(default = "default_sql_parse_cache_capacity")]
pub sql_parse_cache_capacity: u64,
#[serde(default = "default_query_cache_capacity")]
pub query_cache_capacity: u64,
#[serde(default = "default_cache_ttl")]
pub default_ttl: u64,
}
fn default_policy_cache_capacity() -> u64 {
4096
}
fn default_sql_parse_cache_capacity() -> u64 {
1000
}
fn default_query_cache_capacity() -> u64 {
10000
}
fn default_cache_ttl() -> u64 {
300
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
policy_cache_capacity: default_policy_cache_capacity(),
sql_parse_cache_capacity: default_sql_parse_cache_capacity(),
query_cache_capacity: default_query_cache_capacity(),
default_ttl: default_cache_ttl(),
}
}
}
impl CacheConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.policy_cache_capacity == 0 {
return Err(ConfigError::InvalidCacheCapacity(
"policy_cache_capacity must be > 0".to_string(),
));
}
if self.sql_parse_cache_capacity == 0 {
return Err(ConfigError::InvalidCacheCapacity(
"sql_parse_cache_capacity must be > 0".to_string(),
));
}
if self.query_cache_capacity == 0 {
return Err(ConfigError::InvalidCacheCapacity(
"query_cache_capacity must be > 0".to_string(),
));
}
Ok(())
}
pub fn default_ttl_duration(&self) -> Duration {
Duration::from_secs(self.default_ttl)
}
#[cfg(feature = "yaml")]
pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
serde_yaml_ng::from_str(yaml)
}
pub fn from_json_value(v: serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(v)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_idle_timeout")]
pub idle_timeout: u64,
#[serde(default = "default_acquire_timeout")]
pub acquire_timeout: u64,
}
fn default_max_connections() -> u32 {
20
}
fn default_min_connections() -> u32 {
5
}
fn default_idle_timeout() -> u64 {
300
}
fn default_acquire_timeout() -> u64 {
5000
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
max_connections: default_max_connections(),
min_connections: default_min_connections(),
idle_timeout: default_idle_timeout(),
acquire_timeout: default_acquire_timeout(),
}
}
}
impl PoolConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.max_connections == 0 {
return Err(ConfigError::InvalidValue {
key: "max_connections".to_string(),
message: "max_connections must be > 0".to_string(),
});
}
if self.min_connections > self.max_connections {
return Err(ConfigError::InvalidValue {
key: "min_connections".to_string(),
message: format!(
"min_connections ({}) must be <= max_connections ({})",
self.min_connections, self.max_connections
),
});
}
if self.acquire_timeout == 0 {
return Err(ConfigError::InvalidValue {
key: "acquire_timeout".to_string(),
message: "acquire_timeout must be > 0".to_string(),
});
}
Ok(())
}
pub fn idle_timeout_duration(&self) -> Duration {
Duration::from_secs(self.idle_timeout)
}
pub fn acquire_timeout_duration(&self) -> Duration {
Duration::from_millis(self.acquire_timeout)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DatabaseType {
Postgres,
MySql,
#[default]
Sqlite,
DuckDb,
Ladybug,
Neo4j,
}
impl DatabaseType {
pub fn from_url(url: &str) -> Result<Self, crate::error::DbNexusError> {
let lower = url.to_lowercase();
if lower == "sqlite::memory:" || lower.starts_with("sqlite://") || lower.starts_with("sqlite3://") {
return Ok(DatabaseType::Sqlite);
}
if lower.starts_with("duckdb:") {
return Ok(DatabaseType::DuckDb);
}
if lower.starts_with("lbug:") || lower.starts_with("ladybug:") {
return Ok(DatabaseType::Ladybug);
}
if lower.starts_with("neo4j:") || lower.starts_with("neo4j+s:") || lower.starts_with("neo4j+ssc:") {
return Ok(DatabaseType::Neo4j);
}
let parsed = url::Url::parse(url).map_err(|_| {
crate::error::DbNexusError::UnsupportedDatabaseScheme(format!("failed to parse URL: {url}"))
})?;
match parsed.scheme() {
"sqlite" | "sqlite3" => Ok(DatabaseType::Sqlite),
"postgres" | "postgresql" => Ok(DatabaseType::Postgres),
"mysql" => Ok(DatabaseType::MySql),
"duckdb" => Ok(DatabaseType::DuckDb),
"lbug" | "ladybug" => Ok(DatabaseType::Ladybug),
"neo4j" | "neo4j+s" | "neo4j+ssc" => Ok(DatabaseType::Neo4j),
other => Err(crate::error::DbNexusError::UnsupportedDatabaseScheme(format!(
"'{other}' is not a supported database scheme"
))),
}
}
pub fn parse_database_type(url: &str) -> Result<Self, crate::error::DbNexusError> {
Self::from_url(url)
}
pub fn as_str(&self) -> &'static str {
match self {
DatabaseType::Postgres => "postgres",
DatabaseType::MySql => "mysql",
DatabaseType::Sqlite => "sqlite",
DatabaseType::DuckDb => "duckdb",
DatabaseType::Ladybug => "ladybug",
DatabaseType::Neo4j => "neo4j",
}
}
pub fn is_embedded(&self) -> bool {
matches!(self, DatabaseType::Sqlite | DatabaseType::DuckDb)
}
pub fn is_server_side(&self) -> bool {
matches!(self, DatabaseType::Postgres | DatabaseType::MySql)
}
pub fn is_graph(&self) -> bool {
matches!(self, DatabaseType::Ladybug | DatabaseType::Neo4j)
}
pub fn is_real_database(&self) -> bool {
matches!(self, DatabaseType::Postgres | DatabaseType::MySql)
}
}
impl std::fmt::Display for DatabaseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct FailoverConfig {
pub urls: Vec<String>,
#[serde(default)]
pub health_check_query: Option<String>,
#[serde(default = "default_failover_threshold")]
pub failover_threshold: u32,
}
impl Default for FailoverConfig {
fn default() -> Self {
Self {
urls: Vec::new(),
health_check_query: None,
failover_threshold: default_failover_threshold(),
}
}
}
#[allow(dead_code)]
fn default_failover_threshold() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct ReplicaConfig {
pub replica_urls: Vec<String>,
#[serde(default = "default_max_lag_seconds")]
pub max_lag_seconds: f64,
#[serde(default = "default_lag_check_interval")]
pub lag_check_interval_secs: u64,
}
impl Default for ReplicaConfig {
fn default() -> Self {
Self {
replica_urls: Vec::new(),
max_lag_seconds: default_max_lag_seconds(),
lag_check_interval_secs: default_lag_check_interval(),
}
}
}
#[allow(dead_code)]
fn default_max_lag_seconds() -> f64 {
5.0
}
#[allow(dead_code)]
fn default_lag_check_interval() -> u64 {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConfig {
pub url: String,
#[serde(flatten)]
pub pool_config: PoolConfig,
#[serde(default)]
pub permissions_path: Option<String>,
#[serde(default)]
pub migrations_dir: Option<PathBuf>,
#[serde(default)]
pub auto_migrate: bool,
#[serde(default = "default_migration_timeout")]
pub migration_timeout: u64,
#[serde(default = "default_admin_role")]
pub admin_role: String,
#[serde(default = "default_warmup_timeout")]
pub warmup_timeout: u64,
#[serde(default = "default_warmup_retries")]
pub warmup_retries: u32,
#[serde(default)]
pub cache_config: CacheConfig,
#[cfg(feature = "retry")]
#[serde(default)]
pub retry_policy: Option<crate::reliability::RetryPolicy>,
#[cfg(feature = "failover")]
#[serde(default)]
pub failover_config: Option<FailoverConfig>,
#[cfg(feature = "replica-routing")]
#[serde(default)]
pub replica_config: Option<ReplicaConfig>,
}
impl Default for DbConfig {
fn default() -> Self {
Self {
url: String::new(),
pool_config: PoolConfig::default(),
permissions_path: None,
migrations_dir: None,
auto_migrate: false,
migration_timeout: default_migration_timeout(),
admin_role: default_admin_role(),
warmup_timeout: default_warmup_timeout(),
warmup_retries: default_warmup_retries(),
cache_config: CacheConfig::default(),
#[cfg(feature = "retry")]
retry_policy: None,
#[cfg(feature = "failover")]
failover_config: None,
#[cfg(feature = "replica-routing")]
replica_config: None,
}
}
}
fn default_admin_role() -> String {
"admin".to_string()
}
fn default_migration_timeout() -> u64 {
60
}
fn default_warmup_timeout() -> u64 {
30
}
fn default_warmup_retries() -> u32 {
3
}
#[cfg(feature = "config-env")]
fn parse_env_u32(key: &str, default: u32) -> Result<u32, ConfigError> {
match std::env::var(key) {
Ok(val) => val.parse::<u32>().map_err(|_| ConfigError::InvalidValue {
key: key.to_string(),
message: format!("expected u32, got '{val}'"),
}),
Err(_) => Ok(default),
}
}
#[cfg(feature = "config-env")]
fn parse_env_u64(key: &str, default: u64) -> Result<u64, ConfigError> {
match std::env::var(key) {
Ok(val) => val.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
key: key.to_string(),
message: format!("expected u64, got '{val}'"),
}),
Err(_) => Ok(default),
}
}
impl DbConfig {
#[cfg(feature = "config-env")]
pub fn from_env() -> Result<Self, ConfigError> {
let url = std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingUrl)?;
Ok(Self {
url,
pool_config: PoolConfig {
max_connections: parse_env_u32("DB_MAX_CONNECTIONS", 20)?,
min_connections: parse_env_u32("DB_MIN_CONNECTIONS", 5)?,
idle_timeout: parse_env_u64("DB_IDLE_TIMEOUT", 300)?,
acquire_timeout: parse_env_u64("DB_ACQUIRE_TIMEOUT", 5000)?,
},
admin_role: std::env::var("DB_ADMIN_ROLE").unwrap_or_else(|_| "admin".to_string()),
permissions_path: std::env::var("DB_PERMISSIONS_PATH").ok(),
migrations_dir: std::env::var("DB_MIGRATIONS_DIR").ok().map(PathBuf::from),
auto_migrate: std::env::var("DB_AUTO_MIGRATE")
.ok()
.map(|s| s.to_lowercase() == "true")
.unwrap_or(false),
migration_timeout: parse_env_u64("DB_MIGRATION_TIMEOUT", 60)?,
warmup_timeout: parse_env_u64("DB_WARMUP_TIMEOUT", 30)?,
warmup_retries: parse_env_u32("DB_WARMUP_RETRIES", 3)?,
cache_config: CacheConfig::default(),
#[cfg(feature = "retry")]
retry_policy: None,
#[cfg(feature = "failover")]
failover_config: None,
#[cfg(feature = "replica-routing")]
replica_config: None,
})
}
#[cfg(feature = "yaml")]
pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
serde_yaml_ng::from_str(yaml)
}
pub fn from_json_str(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn database_type(&self) -> Result<DatabaseType, crate::error::DbNexusError> {
DatabaseType::from_url(&self.url)
}
pub fn idle_timeout_duration(&self) -> Duration {
self.pool_config.idle_timeout_duration()
}
pub fn acquire_timeout_duration(&self) -> Duration {
self.pool_config.acquire_timeout_duration()
}
pub fn migration_timeout_duration(&self) -> Duration {
Duration::from_secs(self.migration_timeout)
}
pub fn cache_config(&self) -> &CacheConfig {
&self.cache_config
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.cache_config.validate()?;
self.pool_config.validate()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_error_display_variants() {
assert_eq!(
ConfigError::MissingField("url".into()).to_string(),
"Missing required configuration: url"
);
assert_eq!(
ConfigError::MissingUrl.to_string(),
"Missing required configuration: dbnexus.url"
);
assert_eq!(
ConfigError::InvalidCacheCapacity("negative".into()).to_string(),
"Invalid cache capacity: negative"
);
assert_eq!(
ConfigError::InvalidValue {
key: "max".into(),
message: "too large".into(),
}
.to_string(),
"Invalid configuration value for 'max': too large"
);
assert_eq!(
ConfigError::InvalidFormat("yaml".into()).to_string(),
"Invalid configuration format: yaml"
);
assert_eq!(
ConfigError::FileNotFound("/tmp/cfg".into()).to_string(),
"Configuration file not found: /tmp/cfg"
);
assert_eq!(
ConfigError::IoError("read fail".into()).to_string(),
"IO error: read fail"
);
assert_eq!(ConfigError::InvalidUrl("bad".into()).to_string(), "Invalid URL: bad");
assert_eq!(
ConfigError::UnsupportedProtocol("ftp".into()).to_string(),
"Unsupported database protocol: ftp"
);
assert_eq!(
ConfigError::ParseError("syntax".into()).to_string(),
"Parse error: syntax"
);
assert_eq!(
ConfigError::ValidationError("bad value".into()).to_string(),
"Validation error: bad value"
);
}
#[test]
fn test_cache_config_default() {
let cfg = CacheConfig::default();
assert_eq!(cfg.policy_cache_capacity, 4096);
assert_eq!(cfg.sql_parse_cache_capacity, 1000);
assert_eq!(cfg.query_cache_capacity, 10000);
assert_eq!(cfg.default_ttl, 300);
}
#[test]
fn test_cache_config_default_ttl_duration() {
let cfg = CacheConfig::default();
assert_eq!(cfg.default_ttl_duration(), Duration::from_secs(300));
}
#[test]
fn test_cache_config_serde_roundtrip() {
let cfg = CacheConfig {
policy_cache_capacity: 100,
sql_parse_cache_capacity: 200,
query_cache_capacity: 300,
default_ttl: 60,
};
let json = serde_json::to_string(&cfg).unwrap();
let deserialized: CacheConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.policy_cache_capacity, 100);
assert_eq!(deserialized.sql_parse_cache_capacity, 200);
assert_eq!(deserialized.query_cache_capacity, 300);
assert_eq!(deserialized.default_ttl, 60);
}
#[test]
fn test_cache_config_serde_defaults_applied() {
let json = r#"{}"#;
let cfg: CacheConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.policy_cache_capacity, 4096);
assert_eq!(cfg.sql_parse_cache_capacity, 1000);
assert_eq!(cfg.query_cache_capacity, 10000);
assert_eq!(cfg.default_ttl, 300);
}
#[test]
fn test_cache_config_validate_accepts_valid() {
assert!(CacheConfig::default().validate().is_ok());
}
#[test]
fn test_cache_config_validate_rejects_zero_policy_capacity() {
let cfg = CacheConfig {
policy_cache_capacity: 0,
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(
err.to_string().contains("policy_cache_capacity"),
"error should mention field name, got: {err}"
);
}
#[test]
fn test_cache_config_validate_rejects_zero_sql_parse_capacity() {
let cfg = CacheConfig {
sql_parse_cache_capacity: 0,
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("sql_parse_cache_capacity"));
}
#[test]
fn test_cache_config_validate_rejects_zero_query_capacity() {
let cfg = CacheConfig {
query_cache_capacity: 0,
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("query_cache_capacity"));
}
#[test]
fn test_pool_config_default() {
let cfg = PoolConfig::default();
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.min_connections, 5);
assert_eq!(cfg.idle_timeout, 300);
assert_eq!(cfg.acquire_timeout, 5000);
}
#[test]
fn test_pool_config_duration_methods() {
let cfg = PoolConfig::default();
assert_eq!(cfg.idle_timeout_duration(), Duration::from_secs(300));
assert_eq!(cfg.acquire_timeout_duration(), Duration::from_millis(5000));
}
#[test]
fn test_pool_config_serde_defaults_applied() {
let json = r#"{}"#;
let cfg: PoolConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.min_connections, 5);
assert_eq!(cfg.idle_timeout, 300);
assert_eq!(cfg.acquire_timeout, 5000);
}
#[test]
fn test_pool_config_validate_accepts_valid() {
assert!(PoolConfig::default().validate().is_ok());
}
#[test]
fn test_pool_config_validate_rejects_zero_max_connections() {
let cfg = PoolConfig {
max_connections: 0,
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("max_connections"));
}
#[test]
fn test_pool_config_validate_rejects_min_greater_than_max() {
let cfg = PoolConfig {
min_connections: 10,
max_connections: 5,
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("min_connections"));
}
#[test]
fn test_pool_config_validate_rejects_zero_acquire_timeout() {
let cfg = PoolConfig {
acquire_timeout: 0,
..Default::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.to_string().contains("acquire_timeout"));
}
#[test]
fn test_database_type_from_url_postgres() {
assert_eq!(
DatabaseType::from_url("postgres://localhost/db").unwrap(),
DatabaseType::Postgres
);
assert_eq!(
DatabaseType::from_url("postgresql://localhost/db").unwrap(),
DatabaseType::Postgres
);
assert_eq!(
DatabaseType::from_url("POSTGRES://localhost/db").unwrap(),
DatabaseType::Postgres
);
}
#[test]
fn test_database_type_from_url_mysql() {
assert_eq!(
DatabaseType::from_url("mysql://localhost/db").unwrap(),
DatabaseType::MySql
);
assert_eq!(
DatabaseType::from_url("MYSQL://localhost/db").unwrap(),
DatabaseType::MySql
);
}
#[test]
fn test_database_type_from_url_sqlite() {
assert_eq!(DatabaseType::from_url("sqlite::memory:").unwrap(), DatabaseType::Sqlite);
assert_eq!(
DatabaseType::from_url("sqlite://test.db").unwrap(),
DatabaseType::Sqlite
);
}
#[test]
fn test_database_type_from_url_duckdb() {
assert_eq!(DatabaseType::from_url("duckdb::memory:").unwrap(), DatabaseType::DuckDb);
assert_eq!(
DatabaseType::from_url("duckdb://test.db").unwrap(),
DatabaseType::DuckDb
);
assert_eq!(DatabaseType::from_url("duckdb:test.ddb").unwrap(), DatabaseType::DuckDb);
}
#[test]
fn test_database_type_from_url_ladybug() {
assert_eq!(
DatabaseType::from_url("lbug://test.lbug").unwrap(),
DatabaseType::Ladybug
);
assert_eq!(DatabaseType::from_url("lbug:test.lbug").unwrap(), DatabaseType::Ladybug);
assert_eq!(
DatabaseType::from_url("ladybug://test.lbug").unwrap(),
DatabaseType::Ladybug
);
assert_eq!(
DatabaseType::from_url("LBUG://test.lbug").unwrap(),
DatabaseType::Ladybug
);
assert_eq!(
DatabaseType::from_url("Ladybug://test.lbug").unwrap(),
DatabaseType::Ladybug
);
}
#[test]
fn test_database_type_from_url_neo4j() {
assert_eq!(
DatabaseType::from_url("neo4j://user:pass@localhost:7687").unwrap(),
DatabaseType::Neo4j
);
assert_eq!(
DatabaseType::from_url("neo4j+s://user:pass@host:7687").unwrap(),
DatabaseType::Neo4j
);
assert_eq!(
DatabaseType::from_url("neo4j+ssc://user:pass@host:7687").unwrap(),
DatabaseType::Neo4j
);
assert_eq!(
DatabaseType::from_url("NEO4J://localhost:7687").unwrap(),
DatabaseType::Neo4j
);
}
#[test]
fn test_database_type_from_url_unknown_scheme_returns_error() {
assert!(DatabaseType::from_url("unknown://foo").is_err());
assert!(DatabaseType::from_url("").is_err());
assert!(DatabaseType::from_url("/path/to/db.db").is_err());
}
#[test]
fn test_database_type_parse_database_type_alias() {
assert_eq!(
DatabaseType::parse_database_type("mysql://x").unwrap(),
DatabaseType::MySql
);
}
#[test]
fn test_database_type_as_str() {
assert_eq!(DatabaseType::Postgres.as_str(), "postgres");
assert_eq!(DatabaseType::MySql.as_str(), "mysql");
assert_eq!(DatabaseType::Sqlite.as_str(), "sqlite");
assert_eq!(DatabaseType::DuckDb.as_str(), "duckdb");
assert_eq!(DatabaseType::Ladybug.as_str(), "ladybug");
assert_eq!(DatabaseType::Neo4j.as_str(), "neo4j");
}
#[test]
fn test_database_type_is_embedded() {
assert!(DatabaseType::Sqlite.is_embedded());
assert!(DatabaseType::DuckDb.is_embedded());
assert!(!DatabaseType::Postgres.is_embedded());
assert!(!DatabaseType::MySql.is_embedded());
assert!(!DatabaseType::Ladybug.is_embedded());
assert!(!DatabaseType::Neo4j.is_embedded());
}
#[test]
fn test_database_type_is_server_side() {
assert!(DatabaseType::Postgres.is_server_side());
assert!(DatabaseType::MySql.is_server_side());
assert!(!DatabaseType::Sqlite.is_server_side());
assert!(!DatabaseType::DuckDb.is_server_side());
assert!(!DatabaseType::Ladybug.is_server_side());
assert!(!DatabaseType::Neo4j.is_server_side());
}
#[test]
fn test_database_type_is_graph() {
assert!(DatabaseType::Ladybug.is_graph());
assert!(DatabaseType::Neo4j.is_graph());
assert!(!DatabaseType::Postgres.is_graph());
assert!(!DatabaseType::MySql.is_graph());
assert!(!DatabaseType::Sqlite.is_graph());
assert!(!DatabaseType::DuckDb.is_graph());
}
#[test]
fn test_database_type_is_real_database() {
assert!(DatabaseType::Postgres.is_real_database());
assert!(DatabaseType::MySql.is_real_database());
assert!(!DatabaseType::Sqlite.is_real_database());
assert!(!DatabaseType::DuckDb.is_real_database());
assert!(!DatabaseType::Ladybug.is_real_database());
assert!(!DatabaseType::Neo4j.is_real_database());
}
#[test]
fn test_database_type_display() {
assert_eq!(DatabaseType::Postgres.to_string(), "postgres");
assert_eq!(DatabaseType::MySql.to_string(), "mysql");
assert_eq!(DatabaseType::Sqlite.to_string(), "sqlite");
assert_eq!(DatabaseType::DuckDb.to_string(), "duckdb");
assert_eq!(DatabaseType::Ladybug.to_string(), "ladybug");
assert_eq!(DatabaseType::Neo4j.to_string(), "neo4j");
}
#[test]
fn test_database_type_default_is_sqlite() {
let db_type = DatabaseType::default();
assert!(matches!(db_type, DatabaseType::Sqlite));
}
#[test]
fn test_database_type_serde_round_trip() {
let cases = [
DatabaseType::Sqlite,
DatabaseType::Postgres,
DatabaseType::MySql,
DatabaseType::DuckDb,
DatabaseType::Ladybug,
DatabaseType::Neo4j,
];
for original in cases {
let json = serde_json::to_string(&original).expect("serialize should succeed");
let restored: DatabaseType = serde_json::from_str(&json).expect("deserialize should succeed");
assert_eq!(original, restored, "round-trip failed for {:?}", original);
}
}
#[test]
fn test_db_config_default() {
let cfg = DbConfig::default();
assert_eq!(cfg.url, String::new());
assert_eq!(cfg.pool_config.max_connections, 20);
assert_eq!(cfg.pool_config.min_connections, 5);
assert_eq!(cfg.pool_config.idle_timeout, 300);
assert_eq!(cfg.pool_config.acquire_timeout, 5000);
assert_eq!(cfg.admin_role, "admin");
assert_eq!(cfg.migration_timeout, 60);
assert_eq!(cfg.warmup_timeout, 30);
assert_eq!(cfg.warmup_retries, 3);
assert!(!cfg.auto_migrate);
assert!(cfg.permissions_path.is_none());
assert!(cfg.migrations_dir.is_none());
assert_eq!(cfg.cache_config.policy_cache_capacity, 4096);
}
#[test]
fn test_db_config_database_type() {
let cfg = DbConfig {
url: "postgres://localhost/db".into(),
..Default::default()
};
assert_eq!(cfg.database_type().unwrap(), DatabaseType::Postgres);
let cfg = DbConfig {
url: "mysql://localhost/db".into(),
..Default::default()
};
assert_eq!(cfg.database_type().unwrap(), DatabaseType::MySql);
let cfg = DbConfig {
url: "sqlite::memory:".into(),
..Default::default()
};
assert_eq!(cfg.database_type().unwrap(), DatabaseType::Sqlite);
}
#[test]
fn test_db_config_duration_methods() {
let cfg = DbConfig::default();
assert_eq!(cfg.idle_timeout_duration(), Duration::from_secs(300));
assert_eq!(cfg.acquire_timeout_duration(), Duration::from_millis(5000));
assert_eq!(cfg.migration_timeout_duration(), Duration::from_secs(60));
}
#[test]
fn test_db_config_cache_config_ref() {
let cfg = DbConfig::default();
let cache = cfg.cache_config();
assert_eq!(cache.default_ttl, 300);
}
#[test]
fn test_db_config_validate_delegates_to_cache_and_pool() {
assert!(DbConfig::default().validate().is_ok());
let cfg = DbConfig {
cache_config: CacheConfig {
policy_cache_capacity: 0,
..Default::default()
},
..Default::default()
};
assert!(cfg.validate().is_err());
let cfg = DbConfig {
pool_config: PoolConfig {
max_connections: 0,
..Default::default()
},
..Default::default()
};
assert!(cfg.validate().is_err());
let cfg = DbConfig {
pool_config: PoolConfig {
min_connections: 100,
max_connections: 5,
..Default::default()
},
..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_db_config_serde_roundtrip() {
let cfg = DbConfig {
url: "sqlite::memory:".to_string(),
pool_config: PoolConfig {
max_connections: 10,
min_connections: 2,
idle_timeout: 100,
acquire_timeout: 3000,
},
permissions_path: Some("/tmp/perms.yaml".into()),
migrations_dir: Some(PathBuf::from("/tmp/migrations")),
auto_migrate: true,
migration_timeout: 120,
admin_role: "root".to_string(),
warmup_timeout: 15,
warmup_retries: 5,
cache_config: CacheConfig {
policy_cache_capacity: 512,
sql_parse_cache_capacity: 256,
query_cache_capacity: 1024,
default_ttl: 60,
},
#[cfg(feature = "retry")]
retry_policy: None,
#[cfg(feature = "failover")]
failover_config: None,
#[cfg(feature = "replica-routing")]
replica_config: None,
};
let json = serde_json::to_string(&cfg).unwrap();
let deserialized: DbConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.url, "sqlite::memory:");
assert_eq!(deserialized.pool_config.max_connections, 10);
assert_eq!(deserialized.pool_config.min_connections, 2);
assert_eq!(deserialized.pool_config.idle_timeout, 100);
assert_eq!(deserialized.pool_config.acquire_timeout, 3000);
assert_eq!(deserialized.permissions_path, Some("/tmp/perms.yaml".to_string()));
assert_eq!(deserialized.migrations_dir, Some(PathBuf::from("/tmp/migrations")));
assert!(deserialized.auto_migrate);
assert_eq!(deserialized.migration_timeout, 120);
assert_eq!(deserialized.admin_role, "root");
assert_eq!(deserialized.warmup_timeout, 15);
assert_eq!(deserialized.warmup_retries, 5);
assert_eq!(deserialized.cache_config.policy_cache_capacity, 512);
}
#[test]
fn test_db_config_serde_partial_uses_defaults() {
let json = r#"{"url":"sqlite::memory:"}"#;
let cfg: DbConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.url, "sqlite::memory:");
assert_eq!(cfg.pool_config.max_connections, 20);
assert_eq!(cfg.pool_config.min_connections, 5);
assert_eq!(cfg.admin_role, "admin");
assert!(!cfg.auto_migrate);
}
#[test]
fn test_failover_config_default() {
let cfg = FailoverConfig::default();
assert!(cfg.urls.is_empty());
assert!(cfg.health_check_query.is_none());
assert_eq!(cfg.failover_threshold, 3);
}
#[test]
fn test_replica_config_default() {
let cfg = ReplicaConfig::default();
assert!(cfg.replica_urls.is_empty());
assert_eq!(cfg.max_lag_seconds, 5.0);
assert_eq!(cfg.lag_check_interval_secs, 10);
}
#[cfg(feature = "yaml")]
#[test]
fn test_db_config_from_yaml_str() {
let yaml = "url: 'sqlite::memory:'\nadmin_role: dbadmin\n";
let cfg = DbConfig::from_yaml_str(yaml).expect("should deserialize YAML");
assert_eq!(cfg.url, "sqlite::memory:");
assert_eq!(cfg.admin_role, "dbadmin");
}
#[cfg(feature = "yaml")]
#[test]
fn test_db_config_from_yaml_str_minimal() {
let yaml = "url: postgres://localhost/db\n";
let cfg = DbConfig::from_yaml_str(yaml).expect("should deserialize minimal YAML");
assert_eq!(cfg.url, "postgres://localhost/db");
}
#[test]
fn test_db_config_from_json_str() {
let json = r#"{"url":"sqlite::memory:","max_connections":15,"admin_role":"superadmin"}"#;
let cfg = DbConfig::from_json_str(json).expect("should deserialize");
assert_eq!(cfg.url, "sqlite::memory:");
assert_eq!(cfg.pool_config.max_connections, 15);
assert_eq!(cfg.admin_role, "superadmin");
}
#[test]
fn test_db_config_from_json_str_invalid() {
let json = r#"{invalid json}"#;
assert!(DbConfig::from_json_str(json).is_err());
}
#[cfg(feature = "yaml")]
#[test]
fn test_cache_config_from_yaml_str() {
let yaml = "default_ttl: 120\npolicy_cache_capacity: 500\n";
let cfg = CacheConfig::from_yaml_str(yaml).expect("should deserialize YAML");
assert_eq!(cfg.default_ttl, 120);
assert_eq!(cfg.policy_cache_capacity, 500);
}
#[test]
fn test_cache_config_from_json_value() {
let json = serde_json::json!({"default_ttl": 60, "policy_cache_capacity": 100});
let cfg = CacheConfig::from_json_value(json).expect("should deserialize");
assert_eq!(cfg.default_ttl, 60);
assert_eq!(cfg.policy_cache_capacity, 100);
}
#[cfg(feature = "config-env")]
#[test]
fn test_db_config_from_env_missing_url() {
if std::env::var("DATABASE_URL").is_ok() {
eprintln!("Skipping test_db_config_from_env_missing_url: DATABASE_URL is set");
return;
}
let result = DbConfig::from_env();
assert!(result.is_err(), "from_env without DATABASE_URL should fail");
match result.unwrap_err() {
ConfigError::MissingUrl => {} other => panic!("Expected MissingUrl, got: {other:?}"),
}
}
#[cfg(feature = "config-env")]
#[test]
fn test_parse_env_u32_missing_returns_default() {
let result = super::parse_env_u32("DBNEXUS_TEST_NONEXISTENT_U32", 42);
assert_eq!(result.unwrap(), 42);
}
#[cfg(feature = "config-env")]
#[test]
fn test_parse_env_u64_missing_returns_default() {
let result = super::parse_env_u64("DBNEXUS_TEST_NONEXISTENT_U64", 999);
assert_eq!(result.unwrap(), 999);
}
}