feat: support H2 file connections
This commit is contained in:
1 parent
7a2ed233be
commit
9dfc2ba294
19 files changed
+499
-66
No files matched your search
@@ -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'}";
|
||||
|
||||
@@ -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,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.
|
||||
///
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user