feat: support H2 file connections

This commit is contained in:
t8y2 committed 2026-06-08 20:09:27 +08:00
1 parent 7a2ed233be
commit 9dfc2ba294
19 files changed
+499 -66

No files matched your search

+136 -2
View File
@@ -1,12 +1,15 @@
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use crate::path_utils::expand_tilde;
pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, database: &str) -> serde_json::Value {
let agent_database = if config.db_type == DatabaseType::MongoDb {
mongo_agent_database(config, database)
} else if matches!(config.db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle) {
oracle_agent_database(config, database)
} else if is_h2_file_connection(config) {
h2_agent_database(config)
} else {
database.to_string()
};
@@ -20,15 +23,18 @@ pub fn agent_connect_params(config: &ConnectionConfig, host: &str, port: u16, da
sap_hana_jdbc_connection_string(config, host, port, database)
} else if config.db_type == DatabaseType::Trino {
trino_agent_jdbc_connection_string(config, host, port, database)
} else if config.db_type == DatabaseType::H2 {
h2_agent_jdbc_connection_string(config)
} else {
config.connection_string.as_deref().unwrap_or("").to_string()
};
let etcd_endpoints =
if config.db_type == DatabaseType::Etcd { normalize_etcd_endpoints(config, host, port) } else { String::new() };
let (agent_host, agent_port) = if is_h2_file_connection(config) { ("", 0) } else { (host, port) };
serde_json::json!({
"host": host,
"port": port,
"host": agent_host,
"port": agent_port,
"database": agent_database,
"username": config.username,
"password": config.password,
@@ -73,6 +79,85 @@ fn non_empty_database(database: &str) -> Option<&str> {
(!database.is_empty()).then_some(database)
}
pub fn is_h2_file_connection(config: &ConnectionConfig) -> bool {
config.db_type == DatabaseType::H2
&& (config.connection_string.as_deref().is_some_and(is_h2_file_jdbc_url)
|| (config.port == 0 && !config.host.trim().is_empty()))
}
pub fn h2_agent_jdbc_connection_string(config: &ConnectionConfig) -> String {
if let Some(connection_string) =
config.connection_string.as_deref().map(str::trim).filter(|value| !value.is_empty())
{
if is_h2_file_jdbc_url(connection_string) {
return normalize_h2_file_jdbc_url(connection_string).unwrap_or_else(|| connection_string.to_string());
}
return connection_string.to_string();
}
if is_h2_file_connection(config) {
return h2_file_jdbc_url(&config.host);
}
String::new()
}
fn h2_agent_database(config: &ConnectionConfig) -> String {
let jdbc_url = h2_agent_jdbc_connection_string(config);
jdbc_url.strip_prefix("jdbc:h2:").unwrap_or(&jdbc_url).to_string()
}
pub fn h2_file_jdbc_url(path: &str) -> String {
let url = h2_file_jdbc_url_base(path);
format!("{url};AUTO_SERVER=TRUE")
}
fn h2_file_jdbc_url_base(path: &str) -> String {
let path = h2_jdbc_file_base_path(path);
format!("jdbc:h2:file:{path}")
}
pub fn h2_jdbc_file_base_path(path: &str) -> String {
let path = expand_tilde(path.trim());
let lower = path.to_ascii_lowercase();
for suffix in [".mv.db", ".h2.db"] {
if lower.ends_with(suffix) {
return path[..path.len() - suffix.len()].to_string();
}
}
path
}
pub fn h2_file_path_from_jdbc_url(connection_string: &str) -> Option<String> {
let connection_string = connection_string.trim();
let prefix = "jdbc:h2:file:";
if connection_string.get(..prefix.len())?.eq_ignore_ascii_case(prefix) {
return Some(connection_string[prefix.len()..].split(';').next().unwrap_or("").to_string());
}
None
}
fn normalize_h2_file_jdbc_url(connection_string: &str) -> Option<String> {
let connection_string = connection_string.trim();
let prefix = "jdbc:h2:file:";
if !connection_string.get(..prefix.len())?.eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &connection_string[prefix.len()..];
let (path, options) = rest.split_once(';').map(|(path, options)| (path, Some(options))).unwrap_or((rest, None));
let mut url = if options.is_some() { h2_file_jdbc_url_base(path) } else { h2_file_jdbc_url(path) };
if let Some(options) = options {
url.push(';');
url.push_str(options);
}
Some(url)
}
fn is_h2_file_jdbc_url(connection_string: &str) -> bool {
connection_string
.trim()
.get(.."jdbc:h2:file:".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("jdbc:h2:file:"))
}
fn mongo_uri_database(uri: &str) -> Option<String> {
let rest = uri.strip_prefix("mongodb://").or_else(|| uri.strip_prefix("mongodb+srv://"))?;
let (_, after_hosts) = rest.split_once('/')?;
@@ -340,6 +425,55 @@ mod tests {
assert_eq!(params["database"], "app_db");
}
#[test]
fn h2_file_path_builds_jdbc_file_url_and_strips_database_suffix() {
assert_eq!(h2_file_jdbc_url("/tmp/app.mv.db"), "jdbc:h2:file:/tmp/app;AUTO_SERVER=TRUE");
assert_eq!(h2_file_jdbc_url("/tmp/App.MV.DB"), "jdbc:h2:file:/tmp/App;AUTO_SERVER=TRUE");
assert_eq!(h2_file_jdbc_url("/tmp/legacy.h2.db"), "jdbc:h2:file:/tmp/legacy;AUTO_SERVER=TRUE");
assert_eq!(h2_file_jdbc_url("/tmp/app"), "jdbc:h2:file:/tmp/app;AUTO_SERVER=TRUE");
}
#[test]
fn h2_file_connection_passes_jdbc_file_url_to_agent() {
let mut cfg = config(DatabaseType::H2, None);
cfg.host = "/tmp/app.mv.db".to_string();
cfg.port = 0;
let params = agent_connect_params(&cfg, "/tmp/app.mv.db", 0, "");
assert_eq!(params["host"], "");
assert_eq!(params["port"], 0);
assert_eq!(params["database"], "file:/tmp/app;AUTO_SERVER=TRUE");
assert_eq!(params["connection_string"], "jdbc:h2:file:/tmp/app;AUTO_SERVER=TRUE");
}
#[test]
fn h2_file_connection_normalizes_existing_jdbc_file_url_and_preserves_options() {
let mut cfg = config(DatabaseType::H2, None);
cfg.connection_string = Some("jdbc:h2:file:/tmp/app.mv.db;AUTO_SERVER=TRUE".to_string());
let params = agent_connect_params(&cfg, "127.0.0.1", 9092, "test");
assert_eq!(params["host"], "");
assert_eq!(params["port"], 0);
assert_eq!(params["database"], "file:/tmp/app;AUTO_SERVER=TRUE");
assert_eq!(params["connection_string"], "jdbc:h2:file:/tmp/app;AUTO_SERVER=TRUE");
}
#[test]
fn h2_tcp_connection_keeps_empty_agent_connection_string() {
let mut cfg = config(DatabaseType::H2, Some("test"));
cfg.host = "127.0.0.1".to_string();
cfg.port = 9092;
let params = agent_connect_params(&cfg, "127.0.0.1", 9092, "test");
assert_eq!(params["host"], "127.0.0.1");
assert_eq!(params["port"], 9092);
assert_eq!(params["database"], "test");
assert_eq!(params["connection_string"], "");
}
#[test]
fn mongo_auth_hint_preserves_original_error() {
let err = "Agent RPC error: Exception authenticating MongoCredential{mechanism=SCRAM-SHA-1, userName='rwuser', source='admin'}";
+79 -12
View File
@@ -7,8 +7,8 @@ use mysql_async::prelude::Queryable;
use mysql_async::Row as MysqlRow;
use crate::agent_connection::{
agent_connect_params, mongo_legacy_error_with_auth_hint, oracle_alternate_connect_config,
oracle_auth_fallback_profiles, should_retry_oracle_with_10g_driver,
agent_connect_params, h2_file_path_from_jdbc_url, is_h2_file_connection, mongo_legacy_error_with_auth_hint,
oracle_alternate_connect_config, oracle_auth_fallback_profiles, should_retry_oracle_with_10g_driver,
};
use crate::agent_manager::{JavaRuntimeMode, DEFAULT_JRE_KEY};
use crate::database_capabilities;
@@ -20,6 +20,7 @@ use crate::external;
use crate::models::connection::{
parse_jdbc_host_port, parse_mongo_first_host, rewrite_jdbc_url_host, ConnectionConfig, DatabaseType,
};
use crate::path_utils::expand_tilde;
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
use crate::query_cancel::RunningQueries;
use crate::storage::Storage;
@@ -27,15 +28,6 @@ use crate::storage::Storage;
pub const JDBC_PLUGIN_NOT_INSTALLED: &str =
"JDBC plugin is not installed. Install the optional JDBC plugin to use this connection.";
pub fn expand_tilde(path: &str) -> String {
if path == "~" || path.starts_with("~/") {
if let Ok(home) = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) {
return format!("{}{}", home, &path[1..]);
}
}
path.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum MysqlMode {
Normal,
@@ -327,6 +319,7 @@ impl AppState {
let db_config = database_connection_config(&config, database);
validate_h2_file_connection(&db_config)?;
let (host, port) = self.connection_host_port(connection_id, &db_config).await?;
probe_connection_endpoint(&db_config, &host, port).await?;
let url = connection_url_for_endpoint(&db_config, &host, port);
@@ -1070,6 +1063,38 @@ pub async fn probe_connection_endpoint(config: &ConnectionConfig, host: &str, po
db::probe_tcp_endpoint(&format!("{:?}", config.db_type), host, port, timeout).await
}
fn validate_h2_file_connection(config: &ConnectionConfig) -> Result<(), String> {
if !is_h2_file_connection(config) {
return Ok(());
}
let path = config
.connection_string
.as_deref()
.and_then(h2_file_path_from_jdbc_url)
.filter(|path| !path.trim().is_empty())
.unwrap_or_else(|| config.host.clone());
validate_h2_database_path(&path)
}
fn validate_h2_database_path(path: &str) -> Result<(), String> {
let first_err = match db::validate_file_path(path, |_| false) {
Ok(()) => return Ok(()),
Err(err) => err,
};
for suffix in [".mv.db", ".h2.db"] {
if path.ends_with(suffix) {
continue;
}
let candidate = format!("{path}{suffix}");
if db::validate_file_path(&candidate, |_| false).is_ok() {
return Ok(());
}
}
Err(first_err)
}
fn uses_tcp_probe(config: &ConnectionConfig, host: &str, port: u16) -> bool {
if config.db_type == DatabaseType::MongoDb
&& config.connection_string.as_deref().is_some_and(|value| !value.is_empty())
@@ -1123,7 +1148,8 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq
mod tests {
use super::{
connection_url_for_endpoint, database_connection_config, metadata_connection_config,
mysql_metadata_fallback_url, redacted_connection_url_for_endpoint, uses_tcp_probe, AppState, PoolKind,
mysql_metadata_fallback_url, redacted_connection_url_for_endpoint, uses_tcp_probe, validate_h2_database_path,
AppState, PoolKind,
};
use crate::agent_connection::{
agent_connect_params, mongo_legacy_error_with_auth_hint, oracle_alternate_connect_config,
@@ -1197,6 +1223,30 @@ mod tests {
assert_eq!(params["url_params"], "INFORMIXSERVER=informix;CLIENT_LOCALE=en_US.utf8");
}
#[test]
fn validates_h2_database_base_path_when_mv_db_file_exists() {
let dir = std::env::temp_dir().join(format!("dbx-h2-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join("app.mv.db");
std::fs::write(&file_path, b"h2").unwrap();
let base_path = dir.join("app");
validate_h2_database_path(base_path.to_str().unwrap()).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn rejects_missing_h2_database_path() {
let dir = std::env::temp_dir().join(format!("dbx-h2-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let missing_path = dir.join("missing");
let err = validate_h2_database_path(missing_path.to_str().unwrap()).unwrap_err();
assert!(err.contains("File does not exist"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn agent_connect_params_build_mongodb_connection_string_from_form_fields() {
let mut config = mysql_config(Some("RestCloud_V45PUB_Gateway"));
@@ -1784,6 +1834,23 @@ mod tests {
}
}
#[test]
fn h2_agent_connections_skip_tcp_probe_for_file_and_tcp_modes() {
let mut file_config = mysql_config(None);
file_config.db_type = DatabaseType::H2;
file_config.host = "/tmp/app.mv.db".to_string();
file_config.port = 0;
assert!(!uses_tcp_probe(&file_config, "/tmp/app.mv.db", 0));
let mut tcp_config = mysql_config(Some("test"));
tcp_config.db_type = DatabaseType::H2;
tcp_config.host = "127.0.0.1".to_string();
tcp_config.port = 9092;
assert!(!uses_tcp_probe(&tcp_config, "127.0.0.1", 9092));
}
#[tokio::test]
async fn sqlite_get_or_create_pool_initializes_connection_for_web_route() {
let (state, dir) = test_app_state().await;
+1 -22
View File
@@ -1,27 +1,6 @@
use std::path::Path;
fn expand_tilde(path: &str) -> String {
if !path.starts_with('~') {
return path.to_string();
}
if path.len() == 1 {
// path is "~"
return home_dir().unwrap_or_else(|| path.to_string());
}
if path.as_bytes().get(1) == Some(&b'/') {
// path is "~/..."
return match home_dir() {
Some(home) => home + &path[1..],
None => path.to_string(),
};
}
// "~user" or "~something-without-slash" — not a home-dir expansion
path.to_string()
}
fn home_dir() -> Option<String> {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok()
}
use crate::path_utils::expand_tilde;
/// Validates a file path for database connections.
///
+1
View File
@@ -23,6 +23,7 @@ pub mod jdbc;
pub mod models;
pub mod mongo_ops;
pub mod object_source_sql;
pub mod path_utils;
pub mod plugins;
pub mod query;
pub mod query_cancel;
+19
View File
@@ -0,0 +1,19 @@
pub fn expand_tilde(path: &str) -> String {
if !path.starts_with('~') {
return path.to_string();
}
if path.len() == 1 {
return home_dir().unwrap_or_else(|| path.to_string());
}
if path.as_bytes().get(1) == Some(&b'/') {
return match home_dir() {
Some(home) => home + &path[1..],
None => path.to_string(),
};
}
path.to_string()
}
fn home_dir() -> Option<String> {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).ok()
}