use ecat_data::Dialect;
use ecat_tls::TlsClientConfig;
use serde::Deserialize;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MysqlCompat {
#[default]
Mysql,
Doris,
StarRocks,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SqlxConfig {
pub url: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[serde(default)]
pub tls: Option<TlsClientConfig>,
#[serde(default)]
pub session_init: Option<Vec<String>>,
#[serde(default)]
pub mysql_compat: Option<MysqlCompat>,
#[serde(default)]
pub max_connections: Option<u32>,
#[serde(default)]
pub min_connections: Option<u32>,
#[serde(default)]
pub acquire_timeout_secs: Option<u64>,
#[serde(default)]
pub idle_timeout_secs: Option<u64>,
#[serde(default)]
pub max_lifetime_secs: Option<u64>,
#[serde(default)]
pub query_timeout_secs: Option<u64>,
#[serde(default)]
pub slow_query_ms: Option<u64>,
#[serde(default)]
pub test_before_acquire: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct PoolParams {
pub max_connections: u32,
pub min_connections: u32,
pub acquire_timeout: Duration,
pub idle_timeout: Duration,
pub max_lifetime: Duration,
pub query_timeout: Option<Duration>,
pub test_before_acquire: bool,
pub session_init: Vec<String>,
pub slow_query: Option<Duration>,
}
impl Default for PoolParams {
fn default() -> Self {
Self {
max_connections: 10,
min_connections: 0,
acquire_timeout: Duration::from_secs(30),
idle_timeout: Duration::from_secs(600),
max_lifetime: Duration::from_secs(1800),
query_timeout: Some(Duration::from_secs(30)),
test_before_acquire: false,
session_init: Vec::new(),
slow_query: Some(Duration::from_secs(1)),
}
}
}
impl PoolParams {
pub fn for_url(url: &str) -> Self {
Self {
session_init: dialect_session_init(url),
..Self::default()
}
}
}
pub fn dialect_session_init(url: &str) -> Vec<String> {
match Dialect::from_url(url) {
Dialect::Postgres => vec![
"SET TIME ZONE 'UTC'".to_string(),
"SET application_name = 'ecat'".to_string(),
],
Dialect::MySql => vec!["SET time_zone = '+00:00'".to_string()],
_ => Vec::new(),
}
}
impl SqlxConfig {
pub fn pool(&self) -> PoolParams {
let d = PoolParams::default();
let max_connections = self.max_connections.unwrap_or(d.max_connections);
PoolParams {
max_connections,
min_connections: self
.min_connections
.unwrap_or(d.min_connections)
.min(max_connections),
acquire_timeout: self
.acquire_timeout_secs
.map_or(d.acquire_timeout, Duration::from_secs),
idle_timeout: self
.idle_timeout_secs
.map_or(d.idle_timeout, Duration::from_secs),
max_lifetime: self
.max_lifetime_secs
.map_or(d.max_lifetime, Duration::from_secs),
query_timeout: self.query_timeout(),
test_before_acquire: self.test_before_acquire.unwrap_or(d.test_before_acquire),
session_init: self.effective_session_init(),
slow_query: self.slow_query(),
}
}
pub fn query_timeout(&self) -> Option<Duration> {
match self.query_timeout_secs {
None => Some(Duration::from_secs(30)),
Some(0) => None,
Some(s) => Some(Duration::from_secs(s)),
}
}
pub fn slow_query(&self) -> Option<Duration> {
match self.slow_query_ms {
None => Some(Duration::from_secs(1)),
Some(0) => None,
Some(ms) => Some(Duration::from_millis(ms)),
}
}
pub fn effective_session_init(&self) -> Vec<String> {
self.session_init
.clone()
.unwrap_or_else(|| dialect_session_init(&self.url))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minimal_config_uses_documented_defaults() {
let cfg: SqlxConfig =
serde_json::from_str(r#"{"url": "postgres://localhost/db"}"#).unwrap();
let p = cfg.pool();
assert_eq!(p.max_connections, 10);
assert_eq!(p.min_connections, 0);
assert_eq!(p.acquire_timeout, Duration::from_secs(30));
assert_eq!(p.idle_timeout, Duration::from_secs(600));
assert_eq!(p.max_lifetime, Duration::from_secs(1800));
assert_eq!(p.query_timeout, Some(Duration::from_secs(30)));
assert!(!p.test_before_acquire);
assert!(cfg.session_init.is_none());
assert_eq!(p.slow_query, Some(Duration::from_secs(1)));
}
#[test]
fn mysql_compat_is_parsed() {
let cfg: SqlxConfig =
serde_json::from_str(r#"{"url": "mysql://h:9030/db", "mysql_compat": "starrocks"}"#)
.unwrap();
assert_eq!(cfg.mysql_compat, Some(MysqlCompat::StarRocks));
let cfg: SqlxConfig =
serde_json::from_str(r#"{"url": "mysql://h:9030/db", "mysql_compat": "doris"}"#)
.unwrap();
assert_eq!(cfg.mysql_compat, Some(MysqlCompat::Doris));
}
#[test]
fn pool_params_can_be_overridden() {
let cfg: SqlxConfig = serde_json::from_str(
r#"{"url": "mysql://localhost/db",
"max_connections": 32,
"query_timeout_secs": 0,
"test_before_acquire": true,
"session_init": ["SET time_zone = '+00:00'"]}"#,
)
.unwrap();
assert_eq!(cfg.pool().max_connections, 32);
assert_eq!(cfg.pool().query_timeout, None);
assert!(cfg.pool().test_before_acquire);
assert_eq!(
cfg.effective_session_init(),
vec!["SET time_zone = '+00:00'".to_string()]
);
}
#[test]
fn session_init_defaults_per_dialect() {
let pg: SqlxConfig = serde_json::from_str(r#"{"url": "postgres://h/db"}"#).unwrap();
assert!(
pg.effective_session_init()
.iter()
.any(|s| s.contains("TIME ZONE"))
);
let sqlite: SqlxConfig = serde_json::from_str(r#"{"url": "sqlite::memory:"}"#).unwrap();
assert!(sqlite.effective_session_init().is_empty());
}
#[test]
fn for_url_matches_pool_defaults_field_by_field() {
for url in ["postgres://h/db", "mysql://h/db", "sqlite::memory:"] {
let cfg: SqlxConfig = serde_json::from_str(&format!(r#"{{"url": "{url}"}}"#)).unwrap();
let a = PoolParams::for_url(url);
let b = cfg.pool();
assert_eq!(a.max_connections, b.max_connections, "{url}");
assert_eq!(a.min_connections, b.min_connections, "{url}");
assert_eq!(a.acquire_timeout, b.acquire_timeout, "{url}");
assert_eq!(a.idle_timeout, b.idle_timeout, "{url}");
assert_eq!(a.max_lifetime, b.max_lifetime, "{url}");
assert_eq!(a.query_timeout, b.query_timeout, "{url}");
assert_eq!(a.test_before_acquire, b.test_before_acquire, "{url}");
assert_eq!(a.session_init, b.session_init, "{url}");
assert_eq!(a.slow_query, b.slow_query, "{url}");
}
}
#[test]
fn min_connections_is_clamped_to_max() {
let cfg: SqlxConfig = serde_json::from_str(
r#"{"url": "sqlite::memory:", "max_connections": 2, "min_connections": 5}"#,
)
.unwrap();
let p = cfg.pool();
assert_eq!(p.max_connections, 2);
assert_eq!(p.min_connections, 2);
}
#[test]
fn slow_query_ms_is_threshold_with_zero_meaning_off() {
let off: SqlxConfig =
serde_json::from_str(r#"{"url": "sqlite::memory:", "slow_query_ms": 0}"#).unwrap();
assert_eq!(off.slow_query(), None);
assert_eq!(off.pool().slow_query, None);
let on: SqlxConfig =
serde_json::from_str(r#"{"url": "sqlite::memory:", "slow_query_ms": 250}"#).unwrap();
assert_eq!(on.slow_query(), Some(Duration::from_millis(250)));
assert_eq!(on.pool().slow_query, Some(Duration::from_millis(250)));
}
#[test]
fn zero_query_timeout_means_disabled() {
let cfg: SqlxConfig =
serde_json::from_str(r#"{"url": "sqlite::memory:", "query_timeout_secs": 0}"#).unwrap();
assert_eq!(cfg.query_timeout(), None);
}
#[tokio::test]
async fn tls_field_is_rejected_not_silently_ignored() {
let cfg: SqlxConfig =
serde_json::from_str(r#"{"url": "postgres://h/db", "tls": {"skip_verify": true}}"#)
.unwrap();
assert!(
cfg.tls.is_some(),
"serde 应当照旧接受该字段(报错发生在 from_config)"
);
let Err(err) = crate::SqlxClient::from_config(cfg).await else {
panic!("tls 字段设了必须报错,不能静默忽略");
};
let msg = err.to_string();
assert!(msg.contains("tls"), "got: {msg}");
assert!(
msg.contains("sslmode"),
"错误信息要指向正确做法,got: {msg}"
);
}
}