dbx/crates/dbx-core/src/models/connection.rs

2751 lines
104 KiB
Rust

use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ConnectionConfig {
pub id: String,
pub name: String,
pub db_type: DatabaseType,
#[serde(default)]
pub driver_profile: Option<String>,
#[serde(default)]
pub driver_label: Option<String>,
#[serde(default)]
pub url_params: Option<String>,
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub database: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_databases: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible_schemas: Option<HashMap<String, Vec<String>>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attached_databases: Vec<AttachedDatabaseConfig>,
#[serde(default)]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transport_layers: Vec<TransportLayerConfig>,
#[serde(default = "default_connect_timeout_secs")]
pub connect_timeout_secs: u64,
#[serde(default = "default_query_timeout_secs")]
pub query_timeout_secs: u64,
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default = "default_keepalive_interval_secs")]
pub keepalive_interval_secs: u64,
#[serde(default)]
pub ssl: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ca_cert_path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub client_cert_path: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub client_key_path: String,
#[serde(default)]
pub sysdba: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oracle_connection_type: Option<String>,
#[serde(default)]
pub connection_string: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redis_connection_mode: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub redis_sentinel_master: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub redis_sentinel_nodes: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub redis_sentinel_username: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub redis_sentinel_password: String,
#[serde(default, skip_serializing_if = "is_false")]
pub redis_sentinel_tls: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub redis_cluster_nodes: String,
#[serde(default = "default_redis_key_separator", skip_serializing_if = "is_default_redis_separator")]
pub redis_key_separator: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redis_scan_page_size: Option<u64>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub etcd_endpoints: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub gbase_server: String,
/// Informix server name (INFORMIXSERVER). When empty, the agent
/// derives it from the hostname.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub informix_server: String,
/// Typed configuration for external tabular sources.
#[serde(default)]
pub external_config: Option<serde_json::Value>,
#[serde(default)]
pub jdbc_driver_class: Option<String>,
#[serde(default)]
pub jdbc_driver_paths: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub one_time: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub read_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum TransportLayerConfig {
Ssh(SshTunnelConfig),
Proxy(ProxyTunnelConfig),
}
impl TransportLayerConfig {
pub fn id(&self) -> &str {
match self {
TransportLayerConfig::Ssh(layer) => &layer.id,
TransportLayerConfig::Proxy(layer) => &layer.id,
}
}
pub fn name(&self) -> &str {
match self {
TransportLayerConfig::Ssh(layer) => &layer.name,
TransportLayerConfig::Proxy(layer) => &layer.name,
}
}
pub fn enabled(&self) -> bool {
match self {
TransportLayerConfig::Ssh(layer) => layer.enabled,
TransportLayerConfig::Proxy(layer) => layer.enabled,
}
}
pub fn endpoint(&self) -> (&str, u16) {
match self {
TransportLayerConfig::Ssh(layer) => (&layer.host, layer.port),
TransportLayerConfig::Proxy(layer) => (&layer.host, layer.port),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SshTunnelConfig {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub host: String,
#[serde(default = "default_ssh_port")]
pub port: u16,
#[serde(default)]
pub user: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub key_path: String,
#[serde(default)]
pub key_passphrase: String,
#[serde(default = "default_ssh_connect_timeout_secs")]
pub connect_timeout_secs: u64,
#[serde(default)]
pub expose_lan: bool,
#[serde(default)]
pub use_ssh_agent: bool,
/// Custom SSH agent socket path (e.g. `~/.ssh/agent.sock`).
/// When set and `use_ssh_agent` is true, this path is used instead of
/// the `SSH_AUTH_SOCK` environment variable.
#[serde(default)]
pub ssh_agent_sock_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProxyTunnelConfig {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub proxy_type: ProxyType,
#[serde(default)]
pub host: String,
#[serde(default = "default_proxy_port")]
pub port: u16,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AttachedDatabaseConfig {
pub name: String,
pub path: String,
}
fn default_true() -> bool {
true
}
fn default_ssh_port() -> u16 {
22
}
pub fn default_ssh_connect_timeout_secs() -> u64 {
5
}
pub fn default_connect_timeout_secs() -> u64 {
10
}
pub fn default_query_timeout_secs() -> u64 {
30
}
pub fn default_idle_timeout_secs() -> u64 {
60
}
pub fn default_keepalive_interval_secs() -> u64 {
30
}
fn default_proxy_port() -> u16 {
1080
}
fn is_false(value: &bool) -> bool {
!*value
}
pub fn default_redis_key_separator() -> String {
":".to_string()
}
fn is_default_redis_separator(value: &str) -> bool {
value == ":"
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ProxyType {
#[default]
Socks5,
Http,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseType {
Mysql,
Postgres,
Sqlite,
Rqlite,
Redis,
#[serde(rename = "duckdb")]
DuckDb,
#[serde(rename = "clickhouse")]
ClickHouse,
#[serde(rename = "sqlserver")]
SqlServer,
#[serde(rename = "mongodb")]
MongoDb,
#[serde(rename = "oracle")]
Oracle,
#[serde(rename = "elasticsearch")]
Elasticsearch,
#[serde(rename = "qdrant")]
Qdrant,
#[serde(rename = "milvus")]
Milvus,
#[serde(rename = "weaviate")]
Weaviate,
#[serde(rename = "chromadb")]
ChromaDb,
Doris,
#[serde(rename = "starrocks")]
StarRocks,
#[serde(rename = "manticoresearch")]
ManticoreSearch,
Databend,
Redshift,
Dameng,
Kingbase,
Highgo,
Vastbase,
Goldendb,
Gaussdb,
Kwdb,
Yashandb,
Databricks,
#[serde(rename = "saphana")]
SapHana,
Teradata,
Vertica,
Firebird,
Exasol,
#[serde(rename = "opengauss")]
OpenGauss,
#[serde(rename = "oceanbase-oracle")]
OceanbaseOracle,
Gbase,
Access,
#[serde(rename = "h2")]
H2,
Snowflake,
Trino,
#[serde(rename = "prestosql")]
PrestoSql,
Hive,
#[serde(rename = "db2")]
Db2,
Informix,
#[serde(rename = "neo4j")]
Neo4j,
Cassandra,
#[serde(rename = "bigquery")]
Bigquery,
Kylin,
Sundb,
Oscar,
Tdengine,
Xugu,
Iotdb,
Etcd,
#[serde(rename = "zookeeper")]
ZooKeeper,
Nacos,
#[serde(rename = "iris")]
Iris,
#[serde(rename = "turso")]
Turso,
#[serde(rename = "influxdb")]
InfluxDb,
#[serde(rename = "questdb")]
Questdb,
Jdbc,
/// Message queue admin connection (Pulsar / Kafka / RocketMQ). The specific
/// system is determined by `external_config.systemKind`.
#[serde(rename = "mq")]
MessageQueue,
}
#[derive(Deserialize)]
struct ConnectionConfigData {
pub id: String,
pub name: String,
pub db_type: DatabaseType,
#[serde(default)]
pub driver_profile: Option<String>,
#[serde(default)]
pub driver_label: Option<String>,
#[serde(default)]
pub url_params: Option<String>,
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub database: Option<String>,
#[serde(default)]
pub visible_databases: Option<Vec<String>>,
#[serde(default)]
pub visible_schemas: Option<HashMap<String, Vec<String>>>,
#[serde(default)]
pub attached_databases: Vec<AttachedDatabaseConfig>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub transport_layers: Vec<TransportLayerConfig>,
#[serde(default = "default_connect_timeout_secs")]
pub connect_timeout_secs: u64,
#[serde(default = "default_query_timeout_secs")]
pub query_timeout_secs: u64,
#[serde(default = "default_idle_timeout_secs")]
pub idle_timeout_secs: u64,
#[serde(default = "default_keepalive_interval_secs")]
pub keepalive_interval_secs: u64,
#[serde(default)]
pub ssl: bool,
#[serde(default)]
pub ca_cert_path: String,
#[serde(default)]
pub client_cert_path: String,
#[serde(default)]
pub client_key_path: String,
#[serde(default)]
pub sysdba: bool,
#[serde(default)]
pub oracle_connection_type: Option<String>,
#[serde(default)]
pub connection_string: Option<String>,
#[serde(default)]
pub redis_connection_mode: Option<String>,
#[serde(default)]
pub redis_sentinel_master: String,
#[serde(default)]
pub redis_sentinel_nodes: String,
#[serde(default)]
pub redis_sentinel_username: String,
#[serde(default)]
pub redis_sentinel_password: String,
#[serde(default)]
pub redis_sentinel_tls: bool,
#[serde(default)]
pub redis_cluster_nodes: String,
#[serde(default = "default_redis_key_separator")]
pub redis_key_separator: String,
#[serde(default)]
pub redis_scan_page_size: Option<u64>,
#[serde(default)]
pub etcd_endpoints: String,
#[serde(default)]
pub gbase_server: String,
#[serde(default)]
pub informix_server: String,
#[serde(default)]
pub external_config: Option<serde_json::Value>,
#[serde(default)]
pub jdbc_driver_class: Option<String>,
#[serde(default)]
pub jdbc_driver_paths: Vec<String>,
#[serde(default)]
pub one_time: bool,
#[serde(default)]
pub read_only: bool,
}
impl From<ConnectionConfigData> for ConnectionConfig {
fn from(data: ConnectionConfigData) -> Self {
Self {
id: data.id,
name: data.name,
db_type: data.db_type,
driver_profile: data.driver_profile,
driver_label: data.driver_label,
url_params: data.url_params,
host: data.host,
port: data.port,
username: data.username,
password: data.password,
database: data.database,
visible_databases: data.visible_databases,
visible_schemas: data.visible_schemas,
attached_databases: data.attached_databases,
color: data.color,
transport_layers: data.transport_layers,
connect_timeout_secs: data.connect_timeout_secs,
query_timeout_secs: data.query_timeout_secs,
idle_timeout_secs: data.idle_timeout_secs,
keepalive_interval_secs: data.keepalive_interval_secs,
ssl: data.ssl,
ca_cert_path: data.ca_cert_path,
client_cert_path: data.client_cert_path,
client_key_path: data.client_key_path,
sysdba: data.sysdba,
oracle_connection_type: data.oracle_connection_type,
connection_string: data.connection_string,
redis_connection_mode: data.redis_connection_mode,
redis_sentinel_master: data.redis_sentinel_master,
redis_sentinel_nodes: data.redis_sentinel_nodes,
redis_sentinel_username: data.redis_sentinel_username,
redis_sentinel_password: data.redis_sentinel_password,
redis_sentinel_tls: data.redis_sentinel_tls,
redis_cluster_nodes: data.redis_cluster_nodes,
redis_key_separator: data.redis_key_separator,
redis_scan_page_size: data.redis_scan_page_size,
etcd_endpoints: data.etcd_endpoints,
gbase_server: data.gbase_server,
informix_server: data.informix_server,
external_config: data.external_config,
jdbc_driver_class: data.jdbc_driver_class,
jdbc_driver_paths: data.jdbc_driver_paths,
one_time: data.one_time,
read_only: data.read_only,
}
}
}
impl<'de> Deserialize<'de> for ConnectionConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mut value = Value::deserialize(deserializer)?;
migrate_legacy_transport_layers(&mut value);
let data = ConnectionConfigData::deserialize(value).map_err(serde::de::Error::custom)?;
Ok(data.into())
}
}
fn migrate_legacy_transport_layers(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
return;
};
if object.get("transport_layers").and_then(Value::as_array).is_some_and(|layers| !layers.is_empty()) {
return;
}
let mut layers = Vec::new();
let ssh_enabled = object.get("ssh_enabled").and_then(Value::as_bool).unwrap_or(false);
if ssh_enabled {
if let Some(ssh_tunnels) = object.get("ssh_tunnels").and_then(Value::as_array) {
for hop in ssh_tunnels {
let mut layer = hop.clone();
if let Some(layer_object) = layer.as_object_mut() {
layer_object.insert("type".to_string(), Value::String("ssh".to_string()));
}
layers.push(layer);
}
}
if layers.is_empty() && string_field(object, "ssh_host").is_some() {
let mut layer = serde_json::Map::new();
layer.insert("type".to_string(), Value::String("ssh".to_string()));
layer.insert("id".to_string(), Value::String("legacy".to_string()));
layer.insert("enabled".to_string(), Value::Bool(true));
copy_string(object, &mut layer, "ssh_host", "host");
copy_u64(object, &mut layer, "ssh_port", "port", default_ssh_port() as u64);
copy_string(object, &mut layer, "ssh_user", "user");
copy_string(object, &mut layer, "ssh_password", "password");
copy_string(object, &mut layer, "ssh_key_path", "key_path");
copy_string(object, &mut layer, "ssh_key_passphrase", "key_passphrase");
copy_u64(
object,
&mut layer,
"ssh_connect_timeout_secs",
"connect_timeout_secs",
default_ssh_connect_timeout_secs(),
);
copy_bool(object, &mut layer, "ssh_expose_lan", "expose_lan");
layers.push(Value::Object(layer));
}
}
let proxy_enabled = object.get("proxy_enabled").and_then(Value::as_bool).unwrap_or(false);
if proxy_enabled && string_field(object, "proxy_host").is_some() {
let mut layer = serde_json::Map::new();
layer.insert("type".to_string(), Value::String("proxy".to_string()));
layer.insert("id".to_string(), Value::String("legacy-proxy".to_string()));
layer.insert("enabled".to_string(), Value::Bool(true));
copy_string(object, &mut layer, "proxy_type", "proxy_type");
copy_string(object, &mut layer, "proxy_host", "host");
copy_u64(object, &mut layer, "proxy_port", "port", default_proxy_port() as u64);
copy_string(object, &mut layer, "proxy_username", "username");
copy_string(object, &mut layer, "proxy_password", "password");
layers.push(Value::Object(layer));
}
if !layers.is_empty() {
object.insert("transport_layers".to_string(), Value::Array(layers));
}
}
fn string_field(object: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
object.get(key).and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()).map(str::to_string)
}
fn copy_string(
object: &serde_json::Map<String, Value>,
target: &mut serde_json::Map<String, Value>,
from: &str,
to: &str,
) {
if let Some(value) = object.get(from).and_then(Value::as_str) {
target.insert(to.to_string(), Value::String(value.to_string()));
}
}
fn copy_bool(
object: &serde_json::Map<String, Value>,
target: &mut serde_json::Map<String, Value>,
from: &str,
to: &str,
) {
if let Some(value) = object.get(from).and_then(Value::as_bool) {
target.insert(to.to_string(), Value::Bool(value));
}
}
fn copy_u64(
object: &serde_json::Map<String, Value>,
target: &mut serde_json::Map<String, Value>,
from: &str,
to: &str,
default: u64,
) {
let value = object.get(from).and_then(Value::as_u64).filter(|value| *value > 0).unwrap_or(default);
target.insert(to.to_string(), Value::Number(value.into()));
}
impl ConnectionConfig {
pub fn effective_transport_layers(&self) -> Vec<TransportLayerConfig> {
self.transport_layers.iter().filter(|layer| layer.enabled()).cloned().collect()
}
pub fn effective_ssh_tunnels(&self) -> Vec<SshTunnelConfig> {
self.effective_transport_layers()
.into_iter()
.filter_map(|layer| match layer {
TransportLayerConfig::Ssh(ssh) => Some(ssh),
TransportLayerConfig::Proxy(_) => None,
})
.collect()
}
pub fn has_effective_transport_layers(&self) -> bool {
!self.effective_transport_layers().is_empty()
}
pub fn has_effective_ssh_tunnels(&self) -> bool {
self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_)))
}
pub fn effective_connect_timeout_secs(&self) -> u64 {
if self.connect_timeout_secs == 0 {
default_connect_timeout_secs()
} else {
self.connect_timeout_secs.clamp(1, 300)
}
}
pub fn effective_query_timeout_secs(&self) -> u64 {
if self.query_timeout_secs == 0 {
0
} else {
self.query_timeout_secs.max(1)
}
}
pub fn effective_database(&self) -> Option<&str> {
self.database
.as_deref()
.map(str::trim)
.filter(|database| !database.is_empty())
.or_else(|| self.default_database())
}
fn default_database(&self) -> Option<&'static str> {
match self.db_type {
DatabaseType::Postgres => match self.driver_profile.as_deref() {
Some("cockroachdb") => Some("defaultdb"),
_ => Some("postgres"),
},
DatabaseType::Redshift => Some("dev"),
DatabaseType::ClickHouse => Some("default"),
DatabaseType::Rqlite | DatabaseType::Turso => Some("main"),
DatabaseType::Gaussdb | DatabaseType::OpenGauss => Some("postgres"),
DatabaseType::Kwdb => Some("defaultdb"),
DatabaseType::Kingbase | DatabaseType::Vastbase => Some("postgres"),
DatabaseType::Highgo => Some("highgo"),
DatabaseType::Yashandb => Some("yasdb"),
DatabaseType::Oscar => Some("osrdb"),
DatabaseType::Firebird => Some("employee"),
DatabaseType::H2 => Some("test"),
DatabaseType::Informix => Some("sysmaster"),
DatabaseType::Neo4j => Some("neo4j"),
_ => None,
}
}
pub fn needs_bare_mysql(&self) -> bool {
matches!(self.db_type, DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch)
|| self.driver_profile.as_deref().map(|p| p.to_lowercase()).is_some_and(|p| {
matches!(p.as_str(), "doris" | "starrocks" | "manticoresearch" | "selectdb" | "oceanbase")
})
}
pub fn is_starrocks(&self) -> bool {
self.db_type == DatabaseType::StarRocks
|| self.driver_profile.as_deref().is_some_and(|profile| profile.eq_ignore_ascii_case("starrocks"))
}
pub fn bare_mysql_supports_tls(&self) -> bool {
self.is_starrocks()
}
pub fn bare_mysql_uses_tls(&self) -> bool {
if !self.bare_mysql_supports_tls() {
return false;
}
if mysql_url_params_tls_disabled(self.url_params.as_deref()) {
return false;
}
self.mysql_uses_tls()
}
pub fn canonicalized(&self) -> Self {
let mut config = self.clone();
if config.db_type == DatabaseType::Mysql
&& config.driver_profile.as_deref().is_some_and(|profile| profile.eq_ignore_ascii_case("tdengine"))
{
config.db_type = DatabaseType::Tdengine;
if config.port == 0 || config.port == 6030 {
config.port = 6041;
}
config.driver_profile = Some("tdengine".to_string());
if config.driver_label.as_deref().unwrap_or("").trim().is_empty() {
config.driver_label = Some("TDengine".to_string());
}
}
config
}
pub fn uses_redis_sentinel(&self) -> bool {
self.db_type == DatabaseType::Redis
&& self.redis_connection_mode.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("sentinel"))
}
pub fn uses_redis_cluster(&self) -> bool {
self.db_type == DatabaseType::Redis
&& self.redis_connection_mode.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("cluster"))
}
pub fn redis_tls_insecure(&self) -> bool {
self.db_type == DatabaseType::Redis && redis_url_params_enable_insecure(self.url_params.as_deref())
}
pub fn connection_url(&self) -> String {
self.connection_url_with_host(&self.host, self.port)
}
pub fn redacted_connection_url(&self) -> String {
self.redacted_connection_url_with_host(&self.host, self.port)
}
pub fn redacted_connection_url_with_host(&self, host: &str, port: u16) -> String {
let raw_host = host;
let host = bracket_ipv6(host);
let db_part = self.effective_database().map(|d| format!("/{}", encode_url_part(d))).unwrap_or_default();
let params = self.normalized_url_params();
match self.db_type {
DatabaseType::Sqlite | DatabaseType::DuckDb => {
format!("{}?mode=rwc", self.host)
}
DatabaseType::Access => self.host.clone(),
DatabaseType::Redis => {
let scheme = if self.ssl { "rediss" } else { "redis" };
let fragment = self.redis_tls_insecure_fragment();
format!("{scheme}://{host}:{port}/{fragment}")
}
DatabaseType::Mysql
| DatabaseType::Doris
| DatabaseType::StarRocks
| DatabaseType::ManticoreSearch
| DatabaseType::Databend => {
let suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
format!("mysql://{host}:{port}{db_part}{suffix}")
}
DatabaseType::Postgres | DatabaseType::Redshift => {
let suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
format!("postgres://{host}:{port}{db_part}{suffix}")
}
DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port),
DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port),
DatabaseType::Turso => turso_http_url(self, raw_host, port),
DatabaseType::SqlServer => {
format!("server=tcp:{host},{port};database={}", self.database.as_deref().unwrap_or("master"))
}
DatabaseType::MongoDb => {
let is_tunneled = host != self.host.as_str() || port != self.port;
if let Some(cs) = self.connection_string.as_deref().filter(|s| !s.is_empty()) {
let cs = normalize_mongo_uri_direct_connection(cs);
if is_tunneled {
return rewrite_mongo_uri_host(&cs, &host, port);
}
return cs;
}
let mut suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
if is_tunneled && !suffix.contains("directConnection=") {
if suffix.is_empty() {
suffix = "?directConnection=true".to_string();
} else {
suffix.push_str("&directConnection=true");
}
}
let db_part = mongo_uri_db_part_for_suffix(&db_part, &suffix);
format!("mongodb://{host}:{port}{db_part}{suffix}")
}
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
DatabaseType::Elasticsearch
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
| DatabaseType::ChromaDb => {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Dameng => format!("dm://{host}:{port}{db_part}"),
DatabaseType::Kingbase => format!("kingbase://{host}:{port}{db_part}"),
DatabaseType::Highgo => format!("highgo://{host}:{port}{db_part}"),
DatabaseType::Vastbase => format!("vastbase://{host}:{port}{db_part}"),
DatabaseType::Goldendb => format!("goldendb://{host}:{port}{db_part}"),
DatabaseType::Gaussdb => format!("gaussdb://{host}:{port}{db_part}"),
DatabaseType::Kwdb => format!("kwdb://{host}:{port}{db_part}"),
DatabaseType::Yashandb => format!("yashandb://{host}:{port}{db_part}"),
DatabaseType::Databricks => format!("databricks://{host}:{port}{db_part}"),
DatabaseType::SapHana => format!("saphana://{host}:{port}{db_part}"),
DatabaseType::Teradata => format!("teradata://{host}:{port}{db_part}"),
DatabaseType::Vertica => format!("vertica://{host}:{port}{db_part}"),
DatabaseType::Firebird => format!("firebird://{host}:{port}{db_part}"),
DatabaseType::Exasol => format!("exasol://{host}:{port}{db_part}"),
DatabaseType::OpenGauss => format!("opengauss://{host}:{port}{db_part}"),
DatabaseType::OceanbaseOracle => {
let base = format!("oceanbase-oracle://{host}:{port}{db_part}");
if params.is_empty() {
base
} else {
format!("{base}?{params}")
}
}
DatabaseType::Questdb => format!("questdb://{host}:{port}{db_part}"),
DatabaseType::Gbase => format!("gbase://{host}:{port}{db_part}"),
DatabaseType::H2 => format!("h2://{host}:{port}{db_part}"),
DatabaseType::Snowflake => format!("snowflake://{host}/{db_part}"),
DatabaseType::Trino => format!("trino://{host}:{port}{db_part}"),
DatabaseType::PrestoSql => format!("prestosql://{host}:{port}{db_part}"),
DatabaseType::Hive => format!("hive://{host}:{port}{db_part}"),
DatabaseType::Db2 => format!("db2://{host}:{port}{db_part}"),
DatabaseType::Informix => format!("informix://{host}:{port}{db_part}"),
DatabaseType::Neo4j => format!("neo4j://{host}:{port}{db_part}"),
DatabaseType::Cassandra => format!("cassandra://{host}:{port}{db_part}"),
DatabaseType::Bigquery => format!("bigquery://{host}/{db_part}"),
DatabaseType::Kylin => format!("kylin://{host}:{port}{db_part}"),
DatabaseType::Sundb => format!("sundb://{host}:{port}{db_part}"),
DatabaseType::Oscar => format!("oscar://{host}:{port}{db_part}"),
DatabaseType::Tdengine => format!("tdengine://{host}:{port}{db_part}"),
DatabaseType::Xugu => format!("xugu://{host}:{port}{db_part}"),
DatabaseType::Iotdb => {
let base = format!("iotdb://{host}:{port}{db_part}");
if params.is_empty() {
base
} else {
format!("{base}?{params}")
}
}
DatabaseType::Etcd => {
format!("etcd://{host}:{port}")
}
DatabaseType::ZooKeeper => {
format!("zookeeper://{host}:{port}")
}
DatabaseType::Iris => format!("iris://{host}:{port}{db_part}"),
DatabaseType::InfluxDb => {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Jdbc => "jdbc:<redacted>".to_string(),
DatabaseType::MessageQueue => self.message_queue_admin_url(),
DatabaseType::Nacos => self.nacos_admin_url(),
}
}
pub fn connection_url_with_host(&self, host: &str, port: u16) -> String {
let raw_host = host;
let host = bracket_ipv6(host);
let db_part = self.effective_database().map(|d| format!("/{}", encode_url_part(d))).unwrap_or_default();
let username = encode_url_part(&self.username);
let password = encode_url_part(&self.password);
let params = self.normalized_url_params();
match self.db_type {
DatabaseType::Sqlite | DatabaseType::DuckDb => {
format!("{}?mode=rwc", self.host)
}
DatabaseType::Access => self.host.clone(),
DatabaseType::Redis => {
let scheme = if self.ssl { "rediss" } else { "redis" };
let fragment = self.redis_tls_insecure_fragment();
if self.username.is_empty() && self.password.is_empty() {
format!("{scheme}://{host}:{port}/{fragment}")
} else if self.username.is_empty() {
format!("{scheme}://:{password}@{host}:{port}/{fragment}")
} else {
format!("{scheme}://{username}:{password}@{host}:{port}/{fragment}")
}
}
DatabaseType::Mysql
| DatabaseType::Doris
| DatabaseType::StarRocks
| DatabaseType::ManticoreSearch
| DatabaseType::Databend => {
let suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
format!("mysql://{}:{}@{host}:{port}{db_part}{suffix}", username, password)
}
DatabaseType::Postgres | DatabaseType::Redshift => {
let suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password)
}
DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port),
DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port),
DatabaseType::Turso => turso_http_url(self, raw_host, port),
DatabaseType::SqlServer => format!(
"server=tcp:{host},{port};user={};password={};database={}",
self.username,
self.password,
self.database.as_deref().unwrap_or("master")
),
DatabaseType::MongoDb => {
let is_tunneled = host != self.host.as_str() || port != self.port;
if let Some(cs) = self.connection_string.as_deref().filter(|s| !s.is_empty()) {
let cs = normalize_mongo_uri_direct_connection(cs);
if is_tunneled {
return rewrite_mongo_uri_host(&cs, &host, port);
}
return cs;
}
let mut suffix = if params.is_empty() { String::new() } else { format!("?{params}") };
if is_tunneled && !suffix.contains("directConnection=") {
if suffix.is_empty() {
suffix = "?directConnection=true".to_string();
} else {
suffix.push_str("&directConnection=true");
}
}
let db_part = mongo_uri_db_part_for_suffix(&db_part, &suffix);
if self.username.is_empty() {
format!("mongodb://{host}:{port}{db_part}{suffix}")
} else {
format!("mongodb://{username}:{password}@{host}:{port}{db_part}{suffix}")
}
}
DatabaseType::Oracle => {
format!("oracle://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Elasticsearch
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
| DatabaseType::ChromaDb => {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Dameng => {
format!("dm://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Kingbase => {
format!("kingbase://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Highgo => {
format!("highgo://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Vastbase => {
format!("vastbase://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Goldendb => {
format!("goldendb://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Gaussdb => {
format!("gaussdb://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Kwdb => {
format!("kwdb://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Yashandb => {
format!("yashandb://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Databricks => {
format!("databricks://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::SapHana => {
format!("saphana://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Teradata => {
format!("teradata://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Vertica => {
format!("vertica://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Firebird => {
format!("firebird://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Exasol => {
format!("exasol://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::OpenGauss => {
format!("opengauss://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::OceanbaseOracle => {
let base = format!("oceanbase-oracle://{}:{}@{host}:{port}{db_part}", username, password);
if params.is_empty() {
base
} else {
format!("{base}?{params}")
}
}
DatabaseType::Questdb => {
format!("questdb://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Gbase => {
format!("gbase://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::H2 => {
format!("h2://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Snowflake => {
format!("snowflake://{}:{}@{host}/{db_part}", username, password)
}
DatabaseType::Trino => {
format!("trino://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::PrestoSql => {
format!("prestosql://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Hive => {
format!("hive://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Db2 => {
format!("db2://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Informix => {
format!("informix://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Neo4j => {
format!("neo4j://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Cassandra => {
format!("cassandra://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Bigquery => {
format!("bigquery://{}:{}@{host}/{db_part}", username, password)
}
DatabaseType::Kylin => {
format!("kylin://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Sundb => {
format!("sundb://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Oscar => {
format!("oscar://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Tdengine => {
format!("tdengine://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Xugu => {
format!("xugu://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::Iotdb => {
let base = format!("iotdb://{}:{}@{host}:{port}{db_part}", username, password);
if params.is_empty() {
base
} else {
format!("{base}?{params}")
}
}
DatabaseType::Etcd => {
if self.username.is_empty() {
format!("etcd://{host}:{port}")
} else {
format!("etcd://{}:{}@{host}:{port}", username, password)
}
}
DatabaseType::ZooKeeper => {
if self.username.is_empty() {
format!("zookeeper://{host}:{port}")
} else {
format!("zookeeper://{}:{}@{host}:{port}", username, password)
}
}
DatabaseType::Iris => {
format!("iris://{}:{}@{host}:{port}{db_part}", username, password)
}
DatabaseType::InfluxDb => {
let scheme = if self.ssl { "https" } else { "http" };
format!("{scheme}://{host}:{port}")
}
DatabaseType::Jdbc => {
self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string()
}
DatabaseType::MessageQueue => self.message_queue_admin_url(),
DatabaseType::Nacos => self.nacos_admin_url(),
}
}
fn message_queue_admin_url(&self) -> String {
self.external_config
.as_ref()
.and_then(|value| value.get("adminUrl").or_else(|| value.get("admin_url")))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("mq://")
.to_string()
}
fn nacos_admin_url(&self) -> String {
self.external_config
.as_ref()
.and_then(|value| value.get("serverAddr").or_else(|| value.get("server_addr")))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("nacos://")
.to_string()
}
fn normalized_url_params(&self) -> String {
let value = self.url_params.as_deref().unwrap_or("").trim();
if self.needs_bare_mysql() {
if self.bare_mysql_uses_tls() {
return normalize_mysql_url_params(value, true, self.ca_cert_path.trim().is_empty());
}
return normalize_bare_mysql_url_params(value);
}
match self.db_type {
DatabaseType::Mysql => {
normalize_mysql_url_params(value, self.mysql_uses_tls(), self.ca_cert_path.trim().is_empty())
}
DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch => {
let params = normalize_bare_mysql_url_params(value);
if params.is_empty() {
"enable_cleartext_plugin=true".to_string()
} else {
format!("{params}&enable_cleartext_plugin=true")
}
}
DatabaseType::Databend => normalize_bare_mysql_url_params(value),
DatabaseType::Postgres | DatabaseType::Redshift => normalize_postgres_url_params(value, self.ssl),
DatabaseType::MongoDb => normalize_mongo_url_params(value, self.ssl, !self.username.trim().is_empty()),
_ => value.trim_start_matches('?').to_string(),
}
}
pub fn clickhouse_uses_tls(&self) -> bool {
self.ssl || url_params_contains_flag(self.url_params.as_deref(), "secure", "true")
}
pub fn mysql_uses_tls(&self) -> bool {
self.ssl
|| self.host.to_ascii_lowercase().ends_with(".tidbcloud.com")
|| mysql_url_params_require_tls(self.url_params.as_deref())
}
fn redis_tls_insecure_fragment(&self) -> &'static str {
if self.ssl && self.redis_tls_insecure() {
"#insecure"
} else {
""
}
}
}
fn redis_url_params_enable_insecure(params: Option<&str>) -> bool {
params.unwrap_or("").trim().trim_start_matches('?').split(['&', ';']).any(|part| {
let part = part.trim();
if part.is_empty() {
return false;
}
let Some((key, value)) = part.split_once('=') else {
return part.eq_ignore_ascii_case("insecure");
};
let key = key.trim();
matches!(key.to_ascii_lowercase().as_str(), "insecure" | "tls_insecure" | "accept_invalid_certs")
&& matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "insecure")
})
}
fn url_params_contains_flag(params: Option<&str>, key: &str, expected: &str) -> bool {
params.unwrap_or("").trim().trim_start_matches('?').split(['&', ';']).filter_map(|part| part.split_once('=')).any(
|(part_key, value)| part_key.trim().eq_ignore_ascii_case(key) && value.trim().eq_ignore_ascii_case(expected),
)
}
fn mysql_tls_file_param_is(key: &str, target: &str) -> bool {
let normalized = key.to_ascii_lowercase().replace(['-', '_'], "");
normalized == format!("ssl{target}")
}
fn mysql_url_params_tls_disabled(params: Option<&str>) -> bool {
params.unwrap_or("").trim().trim_start_matches('?').split('&').any(|part| {
let part = part.trim();
if part.is_empty() {
return false;
}
let Some((key, value)) = part.split_once('=') else {
return false;
};
let key = key.trim();
let value = value.trim();
(key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("false"))
|| ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
&& matches!(value.to_ascii_lowercase().replace('-', "_").as_str(), "disabled" | "disable"))
})
}
fn mysql_url_params_require_tls(params: Option<&str>) -> bool {
params.unwrap_or("").trim().trim_start_matches('?').split('&').any(|part| {
let part = part.trim();
if part.is_empty() {
return false;
}
let Some((key, value)) = part.split_once('=') else {
return mysql_tls_file_param_is(part, "cert") || mysql_tls_file_param_is(part, "key");
};
let key = key.trim();
let value = value.trim();
(key.eq_ignore_ascii_case("require_ssl") && value.eq_ignore_ascii_case("true"))
|| mysql_tls_file_param_is(key, "cert")
|| mysql_tls_file_param_is(key, "key")
|| ((key.eq_ignore_ascii_case("ssl-mode") || key.eq_ignore_ascii_case("sslmode"))
&& matches!(
value.to_ascii_lowercase().replace('-', "_").as_str(),
"required" | "require" | "verify_ca" | "verify_identity"
))
})
}
fn normalize_bare_mysql_url_params(value: &str) -> String {
value
.trim_start_matches('?')
.split('&')
.filter(|part| {
!part.is_empty()
&& !url_param_key_is(part, "charset")
&& !url_param_key_is(part, "ssl-mode")
&& !url_param_key_is(part, "sslmode")
&& !url_param_key_is(part, "require_ssl")
&& !url_param_key_is(part, "verify_ca")
&& !url_param_key_is(part, "verify_identity")
})
.collect::<Vec<_>>()
.join("&")
}
fn is_mysql_cleartext_password_param(key: &str) -> bool {
matches!(key.to_ascii_lowercase().as_str(), "allowcleartextpasswords" | "enable_cleartext_plugin")
}
fn mysql_url_param_value_is_true(value: &str) -> bool {
matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on")
}
fn normalize_mysql_url_params(value: &str, force_tls: bool, accept_invalid_certs: bool) -> String {
let value = value.trim_start_matches('?');
let mut parts: Vec<String> = value.split('&').filter(|part| !part.is_empty()).map(str::to_string).collect();
let enable_cleartext_plugin = parts.iter().any(|part| {
let Some((key, value)) = part.split_once('=') else {
return false;
};
is_mysql_cleartext_password_param(key.trim()) && mysql_url_param_value_is_true(value)
});
parts.retain(|part| {
let Some((key, _)) = part.split_once('=') else {
return true;
};
!is_mysql_cleartext_password_param(key.trim())
});
if force_tls {
parts.retain(|part| {
!url_param_key_is(part, "ssl-mode")
&& !url_param_key_is(part, "sslmode")
&& !url_param_key_is(part, "require_ssl")
});
parts.insert(0, "require_ssl=true".to_string());
if accept_invalid_certs && !parts.iter().any(|part| url_param_key_is(part, "verify_ca")) {
parts.push("verify_ca=false".to_string());
}
if !parts.iter().any(|part| url_param_key_is(part, "verify_identity")) {
parts.push("verify_identity=false".to_string());
}
} else if !parts.iter().any(|part| {
url_param_key_is(part, "ssl-mode") || url_param_key_is(part, "sslmode") || url_param_key_is(part, "require_ssl")
}) {
parts.insert(0, "ssl-mode=preferred".to_string());
}
if !parts.iter().any(|part| url_param_key_is(part, "charset")) {
parts.push("charset=utf8mb4".to_string());
}
if enable_cleartext_plugin {
parts.push("enable_cleartext_plugin=true".to_string());
}
parts.join("&")
}
fn normalize_mongo_url_params(value: &str, force_tls: bool, default_auth_source: bool) -> String {
let value = value.trim_start_matches('?');
let mut parts: Vec<String> = value.split('&').filter(|part| !part.is_empty()).map(str::to_string).collect();
if force_tls {
parts.retain(|part| !url_param_key_is(part, "tls") && !url_param_key_is(part, "ssl"));
parts.insert(0, "tls=true".to_string());
}
if default_auth_source && !parts.iter().any(|part| url_param_key_is(part, "authSource")) {
parts.push("authSource=admin".to_string());
}
parts.join("&")
}
fn mongo_uri_db_part_for_suffix<'a>(db_part: &'a str, suffix: &str) -> &'a str {
if db_part.is_empty() && !suffix.is_empty() {
"/"
} else {
db_part
}
}
fn normalize_mongo_uri_direct_connection(uri: &str) -> String {
let uri = normalize_mongo_uri_query_path(uri);
if !mongo_uri_has_multiple_seeds(&uri) || !mongo_uri_has_direct_connection_true(&uri) {
return uri;
}
let (before_fragment, fragment) =
uri.split_once('#').map(|(base, fragment)| (base, Some(fragment))).unwrap_or((uri.as_str(), None));
let Some((base, query)) = before_fragment.split_once('?') else {
return uri;
};
let params =
query.split('&').filter(|part| !mongo_url_param_is_direct_connection_true(part)).collect::<Vec<_>>().join("&");
let mut normalized = if params.is_empty() { base.to_string() } else { format!("{base}?{params}") };
if let Some(fragment) = fragment {
normalized.push('#');
normalized.push_str(fragment);
}
normalized
}
fn normalize_mongo_uri_query_path(uri: &str) -> String {
let Some(rest_start) = uri.find("://").map(|idx| idx + "://".len()) else {
return uri.to_string();
};
if !uri[..rest_start].eq_ignore_ascii_case("mongodb://")
&& !uri[..rest_start].eq_ignore_ascii_case("mongodb+srv://")
{
return uri.to_string();
}
let rest = &uri[rest_start..];
let Some(first_path_or_query) = rest.find(['/', '?', '#']) else {
return uri.to_string();
};
if rest.as_bytes()[first_path_or_query] != b'?' {
return uri.to_string();
}
let insert_at = rest_start + first_path_or_query;
format!("{}/{}", &uri[..insert_at], &uri[insert_at..])
}
fn mongo_uri_has_multiple_seeds(uri: &str) -> bool {
if mongo_uri_is_srv(uri) {
// The Rust MongoDB driver resolves SRV records into a seed list, so an
// SRV URL must not keep directConnection=true even with one hostname.
return true;
}
mongo_uri_host_section(uri)
.map(|hosts| hosts.split(',').filter(|host| !host.trim().is_empty()).count() > 1)
.unwrap_or(false)
}
fn mongo_uri_is_srv(uri: &str) -> bool {
uri.get(..14).is_some_and(|scheme| scheme.eq_ignore_ascii_case("mongodb+srv://"))
}
fn mongo_uri_host_section(uri: &str) -> Option<&str> {
let rest = uri.strip_prefix("mongodb://").or_else(|| uri.strip_prefix("mongodb+srv://"))?;
let authority = rest.split('/').next()?.split('?').next().unwrap_or(rest);
Some(match authority.rfind('@') {
Some(idx) => &authority[idx + 1..],
None => authority,
})
}
fn mongo_uri_has_direct_connection_true(uri: &str) -> bool {
uri.split_once('?')
.map(|(_, query)| {
query.split('#').next().unwrap_or("").split('&').any(mongo_url_param_is_direct_connection_true)
})
.unwrap_or(false)
}
fn mongo_url_param_is_direct_connection_true(part: &str) -> bool {
let Some((key, value)) = part.split_once('=') else {
return false;
};
percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case("directConnection")
&& percent_decode_str(value).decode_utf8_lossy().eq_ignore_ascii_case("true")
}
fn normalize_postgres_url_params(value: &str, force_tls: bool) -> String {
let value = value.trim_start_matches('?');
let mut timezone: Option<String> = None;
let mut search_path: Option<String> = None;
let mut parts: Vec<String> = Vec::new();
for part in value.split('&').filter(|part| !part.is_empty()) {
let (raw_key, raw_value) = part.split_once('=').unwrap_or((part, ""));
let key = percent_decode_str(raw_key).decode_utf8_lossy();
if key.eq_ignore_ascii_case("timezone") || key.eq_ignore_ascii_case("time_zone") {
let decoded_value = percent_decode_str(raw_value).decode_utf8_lossy().trim().to_string();
if !decoded_value.is_empty() {
timezone = Some(decoded_value);
}
} else if key.eq_ignore_ascii_case("schema") || key.eq_ignore_ascii_case("currentSchema") {
let decoded_value = percent_decode_str(raw_value).decode_utf8_lossy().trim().to_string();
if !decoded_value.is_empty() {
search_path = Some(decoded_value);
}
} else if key.eq_ignore_ascii_case("ssl-mode") {
let decoded_value = percent_decode_str(raw_value).decode_utf8_lossy();
match decoded_value.to_ascii_lowercase().replace('_', "-").as_str() {
"require" | "required" => parts.push("sslmode=require".to_string()),
"prefer" | "preferred" => parts.push("sslmode=prefer".to_string()),
"disable" | "disabled" => parts.push("sslmode=disable".to_string()),
"verify-ca" => parts.push("sslmode=verify-ca".to_string()),
"verify-full" | "verify-identity" => parts.push("sslmode=verify-full".to_string()),
_ => {}
}
} else if key.eq_ignore_ascii_case("charset")
|| key.eq_ignore_ascii_case("require_ssl")
|| key.eq_ignore_ascii_case("verify_ca")
|| key.eq_ignore_ascii_case("verify_identity")
{
// These MySQL-style parameters may be present in older/imported
// saved connections. tokio-postgres rejects unknown URL keys.
} else {
parts.push(part.to_string());
}
}
let mut connection_options: Vec<(&str, String)> = Vec::new();
if let Some(search_path) = search_path {
connection_options.push(("search_path=", format!("-c search_path={search_path}")));
}
if let Some(timezone) = timezone {
connection_options.push(("timezone=", format!("-c TimeZone={timezone}")));
}
if connection_options.is_empty() {
if force_tls && !parts.iter().any(|part| url_param_key_is(part, "sslmode")) {
parts.insert(0, "sslmode=require".to_string());
}
return parts.join("&");
}
if let Some(options_index) = parts.iter().position(|part| {
part.split_once('=')
.map(|(raw_key, _)| percent_decode_str(raw_key).decode_utf8_lossy().eq_ignore_ascii_case("options"))
.unwrap_or(false)
}) {
let (raw_key, raw_value) = parts[options_index].split_once('=').unwrap_or(("options", ""));
let options_value = percent_decode_str(raw_value).decode_utf8_lossy();
let lower_options = options_value.to_ascii_lowercase();
let appended_options = connection_options
.into_iter()
.filter_map(|(needle, option)| (!lower_options.contains(needle)).then_some(option))
.collect::<Vec<_>>()
.join(" ");
if !appended_options.is_empty() {
let combined = format!("{} {}", options_value.trim(), appended_options).trim().to_string();
parts[options_index] = format!("{raw_key}={}", encode_url_part(&combined));
}
} else {
let combined = connection_options.into_iter().map(|(_, option)| option).collect::<Vec<_>>().join(" ");
parts.push(format!("options={}", encode_url_part(&combined)));
}
if force_tls && !parts.iter().any(|part| url_param_key_is(part, "sslmode")) {
parts.insert(0, "sslmode=require".to_string());
}
parts.join("&")
}
fn url_param_key_is(part: &str, expected: &str) -> bool {
let key = part.split_once('=').map(|(key, _)| key).unwrap_or(part);
percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case(expected)
}
fn clickhouse_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String {
let trimmed = host.trim();
if let Some(rest) = trimmed.strip_prefix("https://") {
return format!("https://{}", trim_clickhouse_host_port(rest, port));
}
if let Some(rest) = trimmed.strip_prefix("http://") {
let scheme = if config.clickhouse_uses_tls() { "https" } else { "http" };
return format!("{scheme}://{}", trim_clickhouse_host_port(rest, port));
}
let scheme = if config.clickhouse_uses_tls() { "https" } else { "http" };
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
}
fn rqlite_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String {
let trimmed = host.trim();
if let Some(rest) = trimmed.strip_prefix("https://") {
return format!("https://{}", trim_http_host_port(rest, port));
}
if let Some(rest) = trimmed.strip_prefix("http://") {
let scheme = if config.ssl { "https" } else { "http" };
return format!("{scheme}://{}", trim_http_host_port(rest, port));
}
let scheme = if config.ssl { "https" } else { "http" };
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
}
fn turso_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String {
let trimmed = host.trim();
// Handle libsql:// protocol (Turso native)
if let Some(rest) = trimmed.strip_prefix("libsql://") {
return format!("https://{}", trim_http_host_port(rest, port));
}
// Handle explicit https://
if let Some(rest) = trimmed.strip_prefix("https://") {
return format!("https://{}", trim_http_host_port(rest, port));
}
// Handle explicit http:// (respect ssl config)
if let Some(rest) = trimmed.strip_prefix("http://") {
let scheme = if config.ssl { "https" } else { "http" };
return format!("{scheme}://{}", trim_http_host_port(rest, port));
}
// Default: bare hostname -> prefer HTTPS for Turso (default port 443)
let scheme = if port == 443 || config.ssl { "https" } else { "http" };
format!("{scheme}://{}:{port}", bracket_ipv6(trimmed))
}
fn trim_http_host_port(value: &str, default_port: u16) -> String {
let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value);
if authority.starts_with('[') && !authority.contains("]:") {
return format!("{authority}:{default_port}");
}
if authority.rsplit_once(':').is_some() {
authority.to_string()
} else {
format!("{authority}:{default_port}")
}
}
fn trim_clickhouse_host_port(value: &str, default_port: u16) -> String {
let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value);
if authority.starts_with('[') && !authority.contains("]:") {
return format!("{authority}:{default_port}");
}
if authority.rsplit_once(':').is_some() {
authority.to_string()
} else {
format!("{authority}:{default_port}")
}
}
pub fn parse_mongo_first_host(uri: &str) -> Option<(String, u16)> {
let rest = uri.strip_prefix("mongodb://").or_else(|| uri.strip_prefix("mongodb+srv://"))?;
let authority = rest.split('/').next()?;
let host_section = match authority.rfind('@') {
Some(idx) => &authority[idx + 1..],
None => authority,
};
let first = host_section.split(',').next()?;
match first.rsplit_once(':') {
Some((h, p)) => Some((h.to_string(), p.parse().unwrap_or(27017))),
None => Some((first.to_string(), 27017)),
}
}
fn rewrite_mongo_uri_host(uri: &str, new_host: &str, new_port: u16) -> String {
let (_scheme, rest) = if let Some(r) = uri.strip_prefix("mongodb+srv://") {
("mongodb://", r)
} else if let Some(r) = uri.strip_prefix("mongodb://") {
("mongodb://", r)
} else {
return uri.to_string();
};
let (creds_prefix, after_creds) = match rest.find('@') {
Some(idx) => (&rest[..=idx], &rest[idx + 1..]),
None => ("", rest),
};
let after_hosts = match after_creds.find('/') {
Some(idx) => &after_creds[idx..],
None => "",
};
let mut result = format!("mongodb://{creds_prefix}{new_host}:{new_port}{after_hosts}");
if !result.contains("directConnection=") {
if result.contains('?') {
result.push_str("&directConnection=true");
} else {
result.push_str("?directConnection=true");
}
}
result
}
pub fn parse_jdbc_host_port(url: &str) -> Option<(String, u16)> {
let rest = url.strip_prefix("jdbc:")?;
// jdbc:oracle:thin:@host:port:SID or jdbc:oracle:thin:@//host:port/service
if let Some(after) = rest.strip_prefix("oracle:") {
let at_pos = after.find('@')?;
let after_at = &after[at_pos + 1..];
if after_at.trim_start().starts_with('(') {
let host = oracle_descriptor_value(after_at, "HOST")?;
let port = oracle_descriptor_value(after_at, "PORT")?;
return Some((host, port.parse().ok()?));
}
let after_at = after_at.strip_prefix("//").unwrap_or(after_at);
let host_port = after_at.split(&['/', ':', '?'][..]).next()?;
let port_str = after_at.strip_prefix(host_port)?.strip_prefix(':')?.split(&[':', '/', ';', '?'][..]).next()?;
return Some((host_port.to_string(), port_str.parse().ok()?));
}
// jdbc:sqlserver://host:port;prop=val or jdbc:sqlserver://host\instance:port;...
if let Some(after) = rest.strip_prefix("sqlserver://") {
let authority = after.split(';').next().unwrap_or(after);
let authority = authority.split('\\').next().unwrap_or(authority);
return match authority.rsplit_once(':') {
Some((h, p)) => Some((h.to_string(), p.parse().ok()?)),
None => Some((authority.to_string(), 1433)),
};
}
// Generic: jdbc:subprotocol://[user:pass@]host:port[/path][?query]
let scheme_end = rest.find("://")?;
let after_scheme = &rest[scheme_end + 3..];
let authority = after_scheme.split('/').next().unwrap_or(after_scheme);
let authority = authority.split('?').next().unwrap_or(authority);
let host_port = match authority.rfind('@') {
Some(idx) => &authority[idx + 1..],
None => authority,
};
match host_port.rsplit_once(':') {
Some((h, p)) => Some((h.to_string(), p.parse().ok()?)),
None => None,
}
}
pub fn rewrite_jdbc_url_host(url: &str, new_host: &str, new_port: u16) -> String {
let normalized_url = url.to_ascii_uppercase();
if normalized_url.starts_with("JDBC:ORACLE:")
&& normalized_url.contains("(HOST=")
&& normalized_url.contains("(PORT=")
{
return rewrite_oracle_descriptor_host(url, new_host, new_port);
}
let Some((old_host, old_port)) = parse_jdbc_host_port(url) else {
return url.to_string();
};
let old_authority = format!("{old_host}:{old_port}");
let new_authority = format!("{new_host}:{new_port}");
url.replacen(&old_authority, &new_authority, 1)
}
fn oracle_descriptor_value(descriptor: &str, key: &str) -> Option<String> {
let key = format!("({key}=");
let start = descriptor.to_ascii_uppercase().find(&key)?;
let value_start = start + key.len();
let value_end = descriptor[value_start..].find(')')? + value_start;
Some(descriptor[value_start..value_end].trim().to_string())
}
fn rewrite_oracle_descriptor_host(url: &str, new_host: &str, new_port: u16) -> String {
let rewritten_host = replace_oracle_descriptor_value(url, "HOST", new_host);
replace_oracle_descriptor_value(&rewritten_host, "PORT", &new_port.to_string())
}
fn replace_oracle_descriptor_value(input: &str, key: &str, value: &str) -> String {
let token = format!("({key}=");
let Some(start) = input.to_ascii_uppercase().find(&token) else {
return input.to_string();
};
let value_start = start + token.len();
let Some(value_end) = input[value_start..].find(')').map(|offset| value_start + offset) else {
return input.to_string();
};
format!("{}{}{}", &input[..value_start], value, &input[value_end..])
}
fn encode_url_part(value: &str) -> String {
utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
}
fn bracket_ipv6(host: &str) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]")
} else {
host.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{
default_query_timeout_secs, default_redis_key_separator, default_ssh_connect_timeout_secs, ConnectionConfig,
DatabaseType, ProxyTunnelConfig, ProxyType, TransportLayerConfig,
};
use std::str::FromStr;
fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
id: "id".to_string(),
name: "name".to_string(),
db_type: DatabaseType::Mysql,
driver_profile: None,
driver_label: None,
url_params: None,
host: "10.1.2.3".to_string(),
port: 2883,
username: username.to_string(),
password: password.to_string(),
database: database.map(str::to_string),
visible_databases: None,
visible_schemas: None,
attached_databases: Vec::new(),
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: super::default_connect_timeout_secs(),
query_timeout_secs: default_query_timeout_secs(),
idle_timeout_secs: super::default_idle_timeout_secs(),
keepalive_interval_secs: super::default_keepalive_interval_secs(),
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: default_redis_key_separator(),
redis_scan_page_size: None,
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
}
}
fn mongodb_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
let mut config = mysql_config(username, password, database);
config.db_type = DatabaseType::MongoDb;
config.port = 17000;
config
}
#[test]
fn zookeeper_database_type_uses_stable_wire_name() {
assert_eq!(serde_json::to_string(&DatabaseType::ZooKeeper).unwrap(), "\"zookeeper\"");
assert_eq!(serde_json::from_str::<DatabaseType>("\"zookeeper\"").unwrap(), DatabaseType::ZooKeeper);
}
#[test]
fn zookeeper_connection_url_uses_zookeeper_scheme() {
let mut config = mysql_config("", "", None);
config.db_type = DatabaseType::ZooKeeper;
config.host = "zk.local".to_string();
config.port = 2181;
assert_eq!(config.connection_url_with_host("zk.local", 2181), "zookeeper://zk.local:2181");
config.username = "digest".to_string();
config.password = "secret".to_string();
assert_eq!(config.connection_url_with_host("zk.local", 2181), "zookeeper://digest:secret@zk.local:2181");
}
#[test]
fn legacy_single_ssh_config_migrates_to_transport_layer() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "id",
"name": "name",
"db_type": "mysql",
"host": "10.1.2.3",
"port": 3306,
"username": "root",
"password": "",
"database": null,
"ssh_enabled": true,
"ssh_host": "bastion.example.com",
"ssh_port": 2200,
"ssh_user": "deploy",
"ssh_password": "secret",
"ssh_connect_timeout_secs": 0,
"ssh_expose_lan": true
}))
.unwrap();
let hops = config.effective_ssh_tunnels();
assert_eq!(hops.len(), 1);
assert_eq!(hops[0].id, "legacy");
assert_eq!(hops[0].host, "bastion.example.com");
assert_eq!(hops[0].port, 2200);
assert_eq!(hops[0].user, "deploy");
assert_eq!(hops[0].password, "secret");
assert_eq!(hops[0].connect_timeout_secs, default_ssh_connect_timeout_secs());
assert!(hops[0].expose_lan);
}
#[test]
fn missing_connection_timeout_defaults_to_ten_seconds() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "id",
"name": "name",
"db_type": "mysql",
"host": "10.1.2.3",
"port": 3306,
"username": "root",
"password": "",
"database": null
}))
.unwrap();
assert_eq!(config.connect_timeout_secs, 10);
assert_eq!(config.effective_connect_timeout_secs(), 10);
}
#[test]
fn legacy_ssh_tunnels_migrate_to_ordered_transport_layers() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "id",
"name": "name",
"db_type": "mysql",
"host": "10.1.2.3",
"port": 3306,
"username": "root",
"password": "",
"database": null,
"ssh_enabled": true,
"ssh_tunnels": [
{ "id": "first", "host": "a", "port": 22, "user": "u" },
{ "id": "second", "host": "b", "port": 2200, "user": "u" }
]
}))
.unwrap();
let hops = config.effective_ssh_tunnels();
assert_eq!(hops.iter().map(|hop| hop.id.as_str()).collect::<Vec<_>>(), vec!["first", "second"]);
}
#[test]
fn legacy_proxy_config_migrates_to_transport_layer() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "id",
"name": "name",
"db_type": "mysql",
"host": "10.1.2.3",
"port": 3306,
"username": "root",
"password": "",
"database": null,
"proxy_enabled": true,
"proxy_type": "http",
"proxy_host": "proxy.example.com",
"proxy_port": 8080,
"proxy_username": "alice",
"proxy_password": "secret"
}))
.unwrap();
assert_eq!(config.transport_layers.len(), 1);
match &config.transport_layers[0] {
TransportLayerConfig::Proxy(proxy) => {
assert_eq!(proxy.id, "legacy-proxy");
assert_eq!(proxy.proxy_type, ProxyType::Http);
assert_eq!(proxy.host, "proxy.example.com");
assert_eq!(proxy.port, 8080);
assert_eq!(proxy.username, "alice");
assert_eq!(proxy.password, "secret");
}
TransportLayerConfig::Ssh(_) => panic!("expected proxy layer"),
}
}
#[test]
fn existing_transport_layers_take_precedence_over_legacy_fields() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "id",
"name": "name",
"db_type": "mysql",
"host": "10.1.2.3",
"port": 3306,
"username": "root",
"password": "",
"database": null,
"ssh_enabled": true,
"ssh_host": "legacy.example.com",
"transport_layers": [{ "type": "proxy", "id": "proxy", "host": "proxy", "port": 1080 }]
}))
.unwrap();
assert_eq!(config.transport_layers.len(), 1);
assert!(matches!(&config.transport_layers[0], TransportLayerConfig::Proxy(proxy) if proxy.id == "proxy"));
}
#[test]
fn serialized_connection_config_omits_legacy_transport_fields() {
let mut config = mysql_config("root", "", None);
config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig {
id: "proxy".to_string(),
name: String::new(),
enabled: true,
proxy_type: ProxyType::Socks5,
host: "proxy".to_string(),
port: 1080,
username: String::new(),
password: String::new(),
})];
let saved = serde_json::to_value(config).unwrap();
assert!(saved.get("transport_layers").is_some());
for key in ["ssh_tunnels", "ssh_host", "ssh_password", "proxy_host", "proxy_password", "proxy_enabled"] {
assert!(saved.get(key).is_none(), "legacy key {key} should not serialize");
}
}
#[test]
fn query_timeout_zero_disables_timeout() {
let mut config = mysql_config("root", "", None);
config.query_timeout_secs = 0;
assert_eq!(config.effective_query_timeout_secs(), 0);
}
#[test]
fn query_timeout_preserves_long_running_exports() {
let mut config = mysql_config("root", "", None);
config.query_timeout_secs = 3600;
assert_eq!(config.effective_query_timeout_secs(), 3600);
}
#[test]
fn mysql_url_encodes_oceanbase_username() {
let config = mysql_config("user@tenant#cluster", "secret", None);
assert_eq!(
config.connection_url(),
"mysql://user%40tenant%23cluster:secret@10.1.2.3:2883?ssl-mode=preferred&charset=utf8mb4"
);
}
#[test]
fn oceanbase_profile_uses_bare_mysql_connection_options() {
let mut config = mysql_config("user@tenant#cluster", "secret", None);
config.driver_profile = Some("oceanbase".to_string());
assert!(config.needs_bare_mysql());
assert_eq!(config.connection_url(), "mysql://user%40tenant%23cluster:secret@10.1.2.3:2883");
}
#[test]
fn starrocks_profile_omits_mysql_ssl_mode_param_when_tls_disabled() {
let mut config = mysql_config("root", "secret", Some("analytics"));
config.driver_profile = Some("starrocks".to_string());
config.url_params = Some(
"ssl-mode=disabled&sslmode=required&require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4"
.to_string(),
);
assert!(config.needs_bare_mysql());
assert!(!config.bare_mysql_uses_tls());
assert_eq!(config.connection_url(), "mysql://root:secret@10.1.2.3:2883/analytics");
}
#[test]
fn starrocks_profile_preserves_mysql_tls_params_when_enabled() {
let mut config = mysql_config("root", "secret", Some("analytics"));
config.driver_profile = Some("starrocks".to_string());
config.ssl = true;
config.url_params = Some("verify_ca=true&verify_identity=false".to_string());
assert!(config.bare_mysql_uses_tls());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/analytics?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4"
);
}
#[test]
fn starrocks_database_type_preserves_mysql_tls_params_when_enabled() {
let mut config = mysql_config("root", "secret", Some("analytics"));
config.db_type = DatabaseType::StarRocks;
config.ssl = true;
config.ca_cert_path = "/tmp/starrocks-ca.pem".to_string();
assert!(config.bare_mysql_uses_tls());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/analytics?require_ssl=true&verify_identity=false&charset=utf8mb4"
);
}
#[test]
fn starrocks_profile_keeps_non_mysql_tls_params() {
let mut config = mysql_config("root", "secret", Some("analytics"));
config.driver_profile = Some("starrocks".to_string());
config.url_params = Some("connect_timeout=10&sessionVariables=query_timeout=60".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/analytics?connect_timeout=10&sessionVariables=query_timeout=60"
);
}
#[test]
fn tdengine_profile_is_canonicalized_to_agent_database_type() {
let mut config = mysql_config("root", "taosdata", Some("power"));
config.driver_profile = Some("tdengine".to_string());
config.driver_label = None;
config.port = 6030;
let canonical = config.canonicalized();
assert_eq!(canonical.db_type, DatabaseType::Tdengine);
assert_eq!(canonical.port, 6041);
assert_eq!(canonical.driver_profile.as_deref(), Some("tdengine"));
assert_eq!(canonical.driver_label.as_deref(), Some("TDengine"));
assert!(!canonical.needs_bare_mysql());
}
#[test]
fn informix_empty_database_uses_sysmaster_for_connection() {
let mut config = mysql_config("informix", "in4mix", None);
config.db_type = DatabaseType::Informix;
config.port = 9088;
assert_eq!(config.effective_database(), Some("sysmaster"));
assert_eq!(config.connection_url(), "informix://informix:in4mix@10.1.2.3:9088/sysmaster");
}
#[test]
fn h2_empty_database_uses_test_for_connection() {
let mut config = mysql_config("sa", "", None);
config.db_type = DatabaseType::H2;
config.port = 9092;
assert_eq!(config.effective_database(), Some("test"));
assert_eq!(config.connection_url(), "h2://sa:@10.1.2.3:9092/test");
}
#[test]
fn neo4j_empty_database_uses_neo4j_for_connection() {
let mut config = mysql_config("neo4j", "secret", None);
config.db_type = DatabaseType::Neo4j;
config.port = 7687;
assert_eq!(config.effective_database(), Some("neo4j"));
assert_eq!(config.connection_url(), "neo4j://neo4j:secret@10.1.2.3:7687/neo4j");
}
#[test]
fn clickhouse_empty_database_uses_default() {
let mut config = mysql_config("default", "", None);
config.db_type = DatabaseType::ClickHouse;
config.port = 8123;
assert_eq!(config.effective_database(), Some("default"));
}
#[test]
fn clickhouse_tls_uses_https_from_ssl_or_secure_param() {
let mut config = mysql_config("default", "", None);
config.db_type = DatabaseType::ClickHouse;
config.port = 8443;
assert_eq!(config.connection_url(), "http://10.1.2.3:8443");
config.ssl = true;
assert_eq!(config.connection_url(), "https://10.1.2.3:8443");
config.ssl = false;
config.url_params = Some("secure=true".to_string());
assert_eq!(config.connection_url(), "https://10.1.2.3:8443");
}
#[test]
fn clickhouse_host_may_include_http_scheme() {
let mut config = mysql_config("default", "", None);
config.db_type = DatabaseType::ClickHouse;
config.host = "https://clickhouse.example.com".to_string();
config.port = 8443;
assert_eq!(config.connection_url(), "https://clickhouse.example.com:8443");
}
#[test]
fn mysql_url_encodes_password_and_database() {
let config = mysql_config("root", "p@ss:word#1", Some("db/name"));
assert_eq!(
config.connection_url(),
"mysql://root:p%40ss%3Aword%231@10.1.2.3:2883/db%2Fname?ssl-mode=preferred&charset=utf8mb4"
);
}
#[test]
fn mysql_url_appends_custom_params() {
let mut config = mysql_config("root", "secret", Some("test"));
config.url_params = Some("charset=utf8mb4".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4"
);
}
#[test]
fn mysql_cleartext_password_auth_alias_normalizes_to_driver_param() {
let mut config = mysql_config("root", "secret", Some("test"));
config.url_params = Some("allowCleartextPasswords=true".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4&enable_cleartext_plugin=true"
);
}
#[test]
fn mysql_cleartext_password_auth_keeps_canonical_driver_param() {
let mut config = mysql_config("root", "secret", Some("test"));
config.url_params = Some("enable_cleartext_plugin=true".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4&enable_cleartext_plugin=true"
);
}
#[test]
fn mysql_cleartext_password_auth_deduplicates_aliases() {
let mut config = mysql_config("root", "secret", Some("test"));
config.url_params =
Some("allowCleartextPasswords=true&enable_cleartext_plugin=true&charset=utf8mb4".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4&enable_cleartext_plugin=true"
);
}
#[test]
fn mysql_cleartext_password_auth_omits_disabled_values() {
let mut config = mysql_config("root", "secret", Some("test"));
config.url_params = Some("allowCleartextPasswords=false&enable_cleartext_plugin=&charset=utf8mb4".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4"
);
}
#[test]
fn mysql_tls_switch_requires_ssl_without_strict_certificate_checks_by_default() {
let mut config = mysql_config("root", "secret", Some("test"));
config.ssl = true;
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4"
);
}
#[test]
fn mysql_tls_switch_uses_ca_cert_for_ca_validation() {
let mut config = mysql_config("root", "secret", Some("test"));
config.ssl = true;
config.ca_cert_path = "/tmp/tidb-ca.pem".to_string();
assert_eq!(
config.connection_url(),
"mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_identity=false&charset=utf8mb4"
);
}
#[test]
fn tidb_cloud_mysql_url_requires_tls() {
let mut config = mysql_config("root", "secret", Some("test"));
config.host = "gateway01.us-west-2.prod.aws.tidbcloud.com".to_string();
config.port = 4000;
config.url_params = Some("require_ssl=false&charset=utf8mb4".to_string());
assert_eq!(
config.connection_url(),
"mysql://root:secret@gateway01.us-west-2.prod.aws.tidbcloud.com:4000/test?require_ssl=true&charset=utf8mb4&verify_ca=false&verify_identity=false"
);
}
#[test]
fn postgres_url_appends_custom_params() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("sslmode=disable".to_string());
assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=disable");
}
#[test]
fn postgres_tls_switch_adds_require_sslmode() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.ssl = true;
assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require");
}
#[test]
fn postgres_url_normalizes_timezone_param_into_options() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("sslmode=require&timezone=Asia/Shanghai".to_string());
assert_eq!(
config.connection_url(),
"postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require&options=%2Dc%20TimeZone%3DAsia%2FShanghai"
);
let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap();
assert_eq!(pg_config.get_options(), Some("-c TimeZone=Asia/Shanghai"));
}
#[test]
fn postgres_url_maps_schema_param_into_search_path_options() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("schema=public".to_string());
assert_eq!(
config.connection_url(),
"postgres://postgres:secret@10.1.2.3:2883/test?options=%2Dc%20search%5Fpath%3Dpublic"
);
let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap();
assert_eq!(pg_config.get_options(), Some("-c search_path=public"));
}
#[test]
fn postgres_url_maps_current_schema_param_into_search_path_options() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("currentSchema=app".to_string());
assert_eq!(
config.connection_url(),
"postgres://postgres:secret@10.1.2.3:2883/test?options=%2Dc%20search%5Fpath%3Dapp"
);
let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap();
assert_eq!(pg_config.get_options(), Some("-c search_path=app"));
}
#[test]
fn postgres_url_ignores_mysql_only_params_from_saved_connections() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("ssl-mode=preferred&charset=utf8mb4".to_string());
assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=prefer");
tokio_postgres::Config::from_str(&config.connection_url()).unwrap();
}
#[test]
fn postgres_url_maps_mysql_ssl_mode_require_to_sslmode() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("ssl-mode=required&verify_ca=false&verify_identity=false".to_string());
assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require");
tokio_postgres::Config::from_str(&config.connection_url()).unwrap();
}
#[test]
fn postgres_url_appends_timezone_to_existing_options() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("options=-c%20statement_timeout%3D5000&TimeZone=UTC".to_string());
assert_eq!(
config.connection_url(),
"postgres://postgres:secret@10.1.2.3:2883/test?options=%2Dc%20statement%5Ftimeout%3D5000%20%2Dc%20TimeZone%3DUTC"
);
}
#[test]
fn postgres_url_keeps_existing_options_timezone() {
let mut config = mysql_config("postgres", "secret", Some("test"));
config.db_type = DatabaseType::Postgres;
config.url_params = Some("options=-c%20TimeZone%3DUTC&timezone=Asia/Shanghai".to_string());
assert_eq!(
config.connection_url(),
"postgres://postgres:secret@10.1.2.3:2883/test?options=-c%20TimeZone%3DUTC"
);
}
#[test]
fn postgres_url_defaults_to_postgres_database_when_omitted() {
let mut config = mysql_config("root", "secret", None);
config.db_type = DatabaseType::Postgres;
assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/postgres");
}
#[test]
fn postgres_url_defaults_to_postgres_database_when_empty() {
let mut config = mysql_config("root", "secret", Some(""));
config.db_type = DatabaseType::Postgres;
assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/postgres");
}
#[test]
fn redshift_url_defaults_to_dev_database_when_empty() {
let mut config = mysql_config("awsuser", "secret", Some(""));
config.db_type = DatabaseType::Redshift;
assert_eq!(config.connection_url(), "postgres://awsuser:secret@10.1.2.3:2883/dev");
}
#[test]
fn cockroachdb_url_defaults_to_defaultdb_database() {
let mut config = mysql_config("root", "secret", None);
config.db_type = DatabaseType::Postgres;
config.driver_profile = Some("cockroachdb".to_string());
assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/defaultdb");
}
#[test]
fn gaussdb_url_defaults_to_postgres_database() {
let mut config = mysql_config("gaussdb", "secret", None);
config.db_type = DatabaseType::Gaussdb;
assert_eq!(config.connection_url(), "gaussdb://gaussdb:secret@10.1.2.3:2883/postgres");
}
#[test]
fn kwdb_url_defaults_to_defaultdb_database() {
let mut config = mysql_config("root", "secret", None);
config.db_type = DatabaseType::Kwdb;
assert_eq!(config.connection_url(), "kwdb://root:secret@10.1.2.3:2883/defaultdb");
}
#[test]
fn yashandb_url_defaults_to_yasdb_database() {
let mut config = mysql_config("sys", "secret", None);
config.db_type = DatabaseType::Yashandb;
assert_eq!(config.connection_url(), "yashandb://sys:secret@10.1.2.3:2883/yasdb");
}
#[test]
fn oscar_url_defaults_to_osrdb_database() {
let mut config = mysql_config("SYSDBA", "secret", None);
config.db_type = DatabaseType::Oscar;
assert_eq!(config.connection_url(), "oscar://SYSDBA:secret@10.1.2.3:2883/osrdb");
}
#[test]
fn mongodb_form_url_without_params_defaults_auth_source_to_admin() {
let config = mongodb_config("root", "secret", Some("admin"));
assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/admin?authSource=admin");
}
#[test]
fn mongodb_form_url_default_database_does_not_change_auth_source() {
let config = mongodb_config("root", "secret", Some("app"));
assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/app?authSource=admin");
}
#[test]
fn mongodb_form_url_without_database_keeps_slash_before_params() {
let config = mongodb_config("root", "secret", None);
assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/?authSource=admin");
assert_eq!(config.redacted_connection_url(), "mongodb://10.1.2.3:17000/?authSource=admin");
}
#[test]
fn mongodb_form_url_without_username_does_not_default_auth_source() {
let config = mongodb_config("", "", Some("app"));
assert_eq!(config.connection_url(), "mongodb://10.1.2.3:17000/app");
}
#[test]
fn mongodb_form_url_preserves_explicit_auth_source() {
let mut config = mongodb_config("root", "secret", Some("app"));
config.url_params = Some("authSource=app".to_string());
assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/app?authSource=app");
}
#[test]
fn mongodb_form_url_appends_custom_params() {
let mut config = mongodb_config("root", "secret", Some("app"));
config.url_params = Some("?authSource=admin&authMechanism=SCRAM-SHA-1&directConnection=true".to_string());
assert_eq!(
config.connection_url(),
"mongodb://root:secret@10.1.2.3:17000/app?authSource=admin&authMechanism=SCRAM-SHA-1&directConnection=true"
);
}
#[test]
fn redacted_mysql_url_omits_credentials() {
let config = mysql_config("user@tenant#cluster", "p@ss:word#1", Some("db/name"));
let url = config.redacted_connection_url();
assert_eq!(url, "mysql://10.1.2.3:2883/db%2Fname?ssl-mode=preferred&charset=utf8mb4");
assert!(!url.contains("user"));
assert!(!url.contains("p%40ss"));
assert!(!url.contains("p@ss"));
}
#[test]
fn redacted_sqlserver_url_omits_credentials() {
let mut config = mysql_config("sa", "super-secret", Some("master"));
config.db_type = DatabaseType::SqlServer;
let url = config.redacted_connection_url();
assert_eq!(url, "server=tcp:10.1.2.3,2883;database=master");
assert!(!url.contains("sa"));
assert!(!url.contains("super-secret"));
}
#[test]
fn redacted_redis_url_omits_credentials_and_keeps_tls_scheme() {
let mut config = mysql_config("default", "redis-secret", None);
config.db_type = DatabaseType::Redis;
config.ssl = true;
let url = config.redacted_connection_url();
assert_eq!(url, "rediss://10.1.2.3:2883/");
assert!(!url.contains("default"));
assert!(!url.contains("redis-secret"));
}
#[test]
fn redis_tls_insecure_url_params_append_insecure_fragment() {
let mut config = mysql_config("default", "secret", Some("0"));
config.db_type = DatabaseType::Redis;
config.ssl = true;
config.url_params = Some("insecure=true".to_string());
assert_eq!(config.connection_url(), "rediss://default:secret@10.1.2.3:2883/#insecure");
assert_eq!(config.redacted_connection_url(), "rediss://10.1.2.3:2883/#insecure");
}
#[test]
fn redis_insecure_url_params_do_not_affect_plain_tcp() {
let mut config = mysql_config("default", "secret", Some("0"));
config.db_type = DatabaseType::Redis;
config.url_params = Some("insecure=true".to_string());
assert_eq!(config.connection_url(), "redis://default:secret@10.1.2.3:2883/");
}
#[test]
fn redacted_mongodb_url_keeps_custom_params_without_credentials() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.url_params = Some("authSource=admin&authMechanism=SCRAM-SHA-1".to_string());
let url = config.redacted_connection_url();
assert_eq!(url, "mongodb://10.1.2.3:17000/admin?authSource=admin&authMechanism=SCRAM-SHA-1");
assert!(!url.contains("root"));
assert!(!url.contains("secret"));
}
#[test]
fn mongodb_form_tls_uses_standard_scheme_and_tls_param() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.ssl = true;
assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/admin?tls=true&authSource=admin");
assert_eq!(config.redacted_connection_url(), "mongodb://10.1.2.3:17000/admin?tls=true&authSource=admin");
}
#[test]
fn mongodb_form_tls_replaces_existing_tls_params() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.ssl = true;
config.url_params = Some("authSource=admin&ssl=false&tls=false".to_string());
assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/admin?tls=true&authSource=admin");
}
#[test]
fn parse_mongo_first_host_replica_set() {
let uri = "mongodb://user:pass@host1:27017,host2:27017,host3:27017/admin?replicaSet=rs0";
let (host, port) = super::parse_mongo_first_host(uri).unwrap();
assert_eq!(host, "host1");
assert_eq!(port, 27017);
}
#[test]
fn parse_mongo_first_host_single() {
let uri = "mongodb://user:pass@myhost:30000/db";
let (host, port) = super::parse_mongo_first_host(uri).unwrap();
assert_eq!(host, "myhost");
assert_eq!(port, 30000);
}
#[test]
fn parse_mongo_first_host_no_creds() {
let uri = "mongodb://host1:27017,host2:27017/admin";
let (host, port) = super::parse_mongo_first_host(uri).unwrap();
assert_eq!(host, "host1");
assert_eq!(port, 27017);
}
#[test]
fn parse_mongo_first_host_srv() {
let uri = "mongodb+srv://user:pass@cluster0.example.net/db";
let (host, port) = super::parse_mongo_first_host(uri).unwrap();
assert_eq!(host, "cluster0.example.net");
assert_eq!(port, 27017);
}
#[test]
fn mongodb_connection_string_rewritten_when_tunneled() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.connection_string =
Some("mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0&authSource=admin".to_string());
let url = config.connection_url_with_host("127.0.0.1", 54321);
assert_eq!(
url,
"mongodb://read:pass@127.0.0.1:54321/admin?replicaSet=rs0&authSource=admin&directConnection=true"
);
}
#[test]
fn mongodb_connection_string_unchanged_when_not_tunneled() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.connection_string = Some("mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0".to_string());
let url = config.connection_url();
assert_eq!(url, "mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0");
}
#[test]
fn mongodb_connection_string_without_database_keeps_slash_before_params() {
let mut config = mongodb_config("root", "secret", None);
config.connection_string = Some("mongodb://read:pass@host1:27017?authSource=admin".to_string());
let url = config.connection_url();
assert_eq!(url, "mongodb://read:pass@host1:27017/?authSource=admin");
}
#[test]
fn mongodb_connection_string_without_database_keeps_slash_when_tunneled() {
let mut config = mongodb_config("root", "secret", None);
config.connection_string = Some("mongodb://read:pass@host1:27017?authSource=admin".to_string());
let url = config.connection_url_with_host("127.0.0.1", 54321);
assert_eq!(url, "mongodb://read:pass@127.0.0.1:54321/?authSource=admin&directConnection=true");
}
#[test]
fn mongodb_multi_seed_connection_string_removes_direct_connection_true() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.connection_string = Some(
"mongodb://read:pass@host1:27017,host2:27017/admin?directConnection=true&replicaSet=rs0&authSource=admin"
.to_string(),
);
let url = config.connection_url();
assert_eq!(url, "mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0&authSource=admin");
}
#[test]
fn mongodb_srv_connection_string_removes_direct_connection_true() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.connection_string = Some(
"mongodb+srv://read:pass@cluster.example.net/admin?tls=true&authSource=admin&directConnection=true&replicaSet=rs0"
.to_string(),
);
let url = config.connection_url();
assert_eq!(url, "mongodb+srv://read:pass@cluster.example.net/admin?tls=true&authSource=admin&replicaSet=rs0");
}
#[test]
fn mongodb_single_seed_connection_string_keeps_direct_connection_true() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.connection_string =
Some("mongodb://read:pass@host1:27017/admin?directConnection=true&authSource=admin".to_string());
let url = config.connection_url();
assert_eq!(url, "mongodb://read:pass@host1:27017/admin?directConnection=true&authSource=admin");
}
#[test]
fn mongodb_form_url_adds_direct_connection_when_tunneled() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.url_params = Some("replicaSet=rs0&authSource=admin".to_string());
let url = config.connection_url_with_host("127.0.0.1", 54321);
assert_eq!(
url,
"mongodb://root:secret@127.0.0.1:54321/admin?replicaSet=rs0&authSource=admin&directConnection=true"
);
}
#[test]
fn mongodb_form_url_without_database_keeps_slash_before_tunneled_params() {
let config = mongodb_config("root", "secret", None);
let url = config.connection_url_with_host("127.0.0.1", 54321);
assert_eq!(url, "mongodb://root:secret@127.0.0.1:54321/?authSource=admin&directConnection=true");
}
#[test]
fn mongodb_form_url_no_duplicate_direct_connection() {
let mut config = mongodb_config("root", "secret", Some("admin"));
config.url_params = Some("directConnection=true&authSource=admin".to_string());
let url = config.connection_url_with_host("127.0.0.1", 54321);
assert!(url.matches("directConnection").count() == 1);
}
#[test]
fn parse_jdbc_host_port_postgresql() {
let (h, p) = super::parse_jdbc_host_port("jdbc:postgresql://myhost:5432/mydb").unwrap();
assert_eq!(h, "myhost");
assert_eq!(p, 5432);
}
#[test]
fn parse_jdbc_host_port_mysql() {
let (h, p) = super::parse_jdbc_host_port("jdbc:mysql://db.example.com:3306/app?useSSL=false").unwrap();
assert_eq!(h, "db.example.com");
assert_eq!(p, 3306);
}
#[test]
fn parse_jdbc_host_port_with_userinfo() {
let (h, p) = super::parse_jdbc_host_port("jdbc:postgresql://user:pass@pghost:5433/db").unwrap();
assert_eq!(h, "pghost");
assert_eq!(p, 5433);
}
#[test]
fn parse_jdbc_host_port_oracle_thin() {
let (h, p) = super::parse_jdbc_host_port("jdbc:oracle:thin:@orahost:1521:ORCL").unwrap();
assert_eq!(h, "orahost");
assert_eq!(p, 1521);
}
#[test]
fn parse_jdbc_host_port_oracle_service() {
let (h, p) = super::parse_jdbc_host_port("jdbc:oracle:thin:@//orahost:1521/service").unwrap();
assert_eq!(h, "orahost");
assert_eq!(p, 1521);
}
#[test]
fn parse_jdbc_host_port_oracle_descriptor() {
let (h, p) = super::parse_jdbc_host_port(
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=orahost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=orcl)))",
)
.unwrap();
assert_eq!(h, "orahost");
assert_eq!(p, 1521);
}
#[test]
fn rewrite_jdbc_url_host_oracle_descriptor() {
let url =
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=orahost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=orcl)))";
assert_eq!(
super::rewrite_jdbc_url_host(url, "127.0.0.1", 11521),
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=11521))(CONNECT_DATA=(SERVICE_NAME=orcl)))"
);
}
#[test]
fn parse_jdbc_host_port_sqlserver() {
let (h, p) = super::parse_jdbc_host_port("jdbc:sqlserver://mshost:1433;databaseName=master").unwrap();
assert_eq!(h, "mshost");
assert_eq!(p, 1433);
}
#[test]
fn parse_jdbc_host_port_sqlserver_no_port() {
let (h, p) = super::parse_jdbc_host_port("jdbc:sqlserver://mshost;databaseName=master").unwrap();
assert_eq!(h, "mshost");
assert_eq!(p, 1433);
}
#[test]
fn parse_jdbc_host_port_no_port_returns_none() {
assert!(super::parse_jdbc_host_port("jdbc:postgresql://myhost/mydb").is_none());
}
#[test]
fn parse_jdbc_host_port_invalid_returns_none() {
assert!(super::parse_jdbc_host_port("not-a-jdbc-url").is_none());
}
#[test]
fn rewrite_jdbc_url_postgresql() {
let url = "jdbc:postgresql://myhost:5432/mydb";
let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321);
assert_eq!(rewritten, "jdbc:postgresql://127.0.0.1:54321/mydb");
}
#[test]
fn rewrite_jdbc_url_oracle() {
let url = "jdbc:oracle:thin:@orahost:1521:ORCL";
let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321);
assert_eq!(rewritten, "jdbc:oracle:thin:@127.0.0.1:54321:ORCL");
}
#[test]
fn rewrite_jdbc_url_sqlserver() {
let url = "jdbc:sqlserver://mshost:1433;databaseName=master";
let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321);
assert_eq!(rewritten, "jdbc:sqlserver://127.0.0.1:54321;databaseName=master");
}
#[test]
fn rewrite_jdbc_url_unparseable_returns_original() {
let url = "jdbc:custom:some-opaque-string";
let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321);
assert_eq!(rewritten, url);
}
}