feat: support H2 file connections

This commit is contained in:
t8y2
2026-06-08 20:09:27 +08:00
parent 7a2ed233be
commit 9dfc2ba294
19 changed files with 499 additions and 66 deletions
@@ -26,6 +26,12 @@ import { isTauriRuntime } from "@/lib/tauriRuntime";
import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connectionUrl";
import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connectionPresentation";
import {
h2ConnectionModeForConfig,
h2FileJdbcUrl,
h2FilePathFromJdbcUrl,
type H2ConnectionMode,
} from "@/lib/h2Connection";
import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/mongoConnectionOptions";
import { copyToClipboard } from "@/lib/clipboard";
import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint";
@@ -264,6 +270,7 @@ const agentDrivers = ref<AgentDriverInstallState[]>([]);
const selectedJdbcDriverPath = ref("");
const connectionUrlInput = ref("");
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
const h2ConnectionMode = ref<H2ConnectionMode>("file");
const dialogStep = ref<DialogStep>("select");
const dbPickerView = ref<DbPickerView>("icon");
const dbSearchQuery = ref("");
@@ -467,6 +474,12 @@ function applyProfile(val: string, preserveConnectionFields = false) {
if (profile.type === "sqlite" || profile.type === "duckdb" || profile.type === "access") {
form.value.host = "";
}
if (profile.type === "h2") {
h2ConnectionMode.value = "file";
form.value.host = "";
form.value.port = 0;
form.value.connection_string = undefined;
}
if (profile.type === "jdbc") {
form.value.host = "";
form.value.connection_string = "";
@@ -508,7 +521,7 @@ watch(
driver_profile: profile,
driver_label: config.driver_label || driverProfiles[profile]?.label || config.db_type,
url_params: config.url_params || "",
host: config.host,
host: config.db_type === "h2" ? config.host || h2FilePathFromJdbcUrl(config.connection_string) : config.host,
port: profile === "tdengine" && (config.port === 0 || config.port === 6030) ? 6041 : config.port,
username: config.username,
password: config.password,
@@ -535,6 +548,7 @@ watch(
redis_cluster_nodes: config.redis_cluster_nodes || "",
etcd_endpoints: config.etcd_endpoints || "",
};
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
selectedTransportLayerId.value = form.value.transport_layers?.[0]?.id || null;
selectedType.value = profile;
if (profile === "oceanbase") {
@@ -555,6 +569,7 @@ watch(
selectedType.value = "mysql";
customDriverName.value = "";
oceanbaseSubMode.value = "mysql";
h2ConnectionMode.value = "file";
dialogStep.value = "select";
configTab.value = "connection";
}
@@ -616,6 +631,7 @@ function defaultDatabaseForProfile() {
return "postgres";
if (form.value.db_type === "sqlserver") return "master";
if (form.value.db_type === "oracle") return "ORCL";
if (form.value.db_type === "h2" && h2ConnectionMode.value === "tcp") return "test";
return "";
}
@@ -625,6 +641,26 @@ function onDbTypeChange(val: string) {
resetTestState();
}
function switchH2ConnectionMode(mode: H2ConnectionMode) {
h2ConnectionMode.value = mode;
if (mode === "file") {
form.value.host = h2FilePathFromJdbcUrl(form.value.connection_string) || "";
form.value.port = 0;
} else {
form.value.host =
form.value.host.trim() && !isH2FileJdbcUrlLikePath(form.value.host) ? form.value.host : "127.0.0.1";
form.value.port = form.value.port || 9092;
if (form.value.connection_string && h2FilePathFromJdbcUrl(form.value.connection_string)) {
form.value.connection_string = undefined;
}
}
resetTestState();
}
function isH2FileJdbcUrlLikePath(value: string): boolean {
return /\.(mv|h2)\.db$/i.test(value.trim()) || value.includes("/") || value.includes("\\");
}
const iconTypeMap: Record<string, string> = {
mysql: "mysql",
postgres: "postgres",
@@ -776,11 +812,20 @@ const filteredDbCategories = computed<DbCategory[]>(() => {
const hasDbPickerResults = computed(() => filteredDbCategories.value.some((category) => category.options.length > 0));
const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value);
const isJdbcConnection = computed(() => form.value.db_type === "jdbc");
const isH2FileMode = computed(() => form.value.db_type === "h2" && h2ConnectionMode.value === "file");
const usesLocalFilePathInput = computed(
() =>
form.value.db_type === "sqlite" ||
form.value.db_type === "duckdb" ||
form.value.db_type === "access" ||
isH2FileMode.value,
);
const connectionUrlPlaceholder = computed(() => getUrlPlaceholder(form.value.db_type));
const filePathPlaceholder = computed(() => {
if (form.value.db_type === "duckdb") return "/path/to/database.duckdb or :memory:";
if (form.value.db_type === "access") return "/path/to/database.accdb";
if (form.value.db_type === "h2") return "/path/to/database.mv.db";
return "/path/to/database.db or :memory:";
});
const supportsMemoryDatabasePath = computed(() => form.value.db_type === "sqlite" || form.value.db_type === "duckdb");
@@ -872,7 +917,9 @@ const etcdEndpointsLines = computed({
form.value.etcd_endpoints = normalizeEndpointLines(value);
},
});
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access");
const canUseTransportLayers = computed(
() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isH2FileMode.value,
);
const shouldShowAgentDriverInstallHint = computed(() =>
showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile),
);
@@ -880,6 +927,15 @@ const testResultMessage = computed(() => {
if (!testResult.value) return "";
return testResult.value.ok ? t("connection.testSuccess") : testResult.value.message;
});
const hasRequiredConnectionTarget = computed(() => {
if (isH2FileMode.value) return !!(form.value.host.trim() || h2FilePathFromJdbcUrl(form.value.connection_string));
return !!(
form.value.host ||
(mongoUseUrl.value && form.value.connection_string) ||
(form.value.db_type === "jdbc" && form.value.connection_string) ||
connectionUrlInput.value.trim()
);
});
const mongoAuthDatabase = computed({
get: () => mongoUrlParam(form.value.url_params, "authSource"),
set: (value: string) => {
@@ -1068,6 +1124,26 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
.map((path) => path.trim())
.filter(Boolean);
}
if (config.db_type === "h2") {
if (h2ConnectionMode.value === "file") {
const filePath = config.host?.trim() || h2FilePathFromJdbcUrl(config.connection_string);
if (!filePath) {
throw new Error(t("connection.h2FilePathRequired"));
}
config.host = filePath;
config.port = 0;
config.connection_string = h2FileJdbcUrl(filePath);
config.transport_layers = [];
} else {
config.host = config.host?.trim() || "127.0.0.1";
config.port = Number(config.port) || 9092;
if (h2FilePathFromJdbcUrl(config.connection_string)) {
config.connection_string = undefined;
} else {
config.connection_string = config.connection_string?.trim() || undefined;
}
}
}
const legacy = config as LegacyConnectionConfig;
delete legacy.ssh_enabled;
delete legacy.ssh_host;
@@ -1704,7 +1780,9 @@ async function browseDbFilePath() {
? [{ name: "DuckDB", extensions: ["duckdb", "db"] }]
: form.value.db_type === "access"
? [{ name: "Microsoft Access", extensions: ["accdb", "mdb"] }]
: [{ name: "SQLite", extensions: ["db", "sqlite", "sqlite3"] }];
: form.value.db_type === "h2"
? [{ name: "H2", extensions: ["db"] }]
: [{ name: "SQLite", extensions: ["db", "sqlite", "sqlite3"] }];
const selected = await open({
title: "Select Database File",
multiple: false,
@@ -2074,6 +2152,26 @@ function openExternalUrl(url: string) {
</div>
</div>
<div v-if="form.db_type === 'h2'" class="grid grid-cols-4 items-center gap-4">
<Label class="text-right text-xs">{{ t("connection.mode") }}</Label>
<div class="col-span-3 flex gap-2">
<Button
size="sm"
:variant="h2ConnectionMode === 'file' ? 'default' : 'outline'"
@click="switchH2ConnectionMode('file')"
>
{{ t("connection.h2FileMode") }}
</Button>
<Button
size="sm"
:variant="h2ConnectionMode === 'tcp' ? 'default' : 'outline'"
@click="switchH2ConnectionMode('tcp')"
>
{{ t("connection.h2TcpMode") }}
</Button>
</div>
</div>
<!-- JDBC: optional external plugin -->
<template v-if="form.db_type === 'jdbc'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -2157,9 +2255,7 @@ function openExternalUrl(url: string) {
</template>
<!-- Local database files: file path only -->
<template
v-else-if="form.db_type === 'sqlite' || form.db_type === 'duckdb' || form.db_type === 'access'"
>
<template v-else-if="usesLocalFilePathInput">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.filePath") }}</Label>
<div class="col-span-3 space-y-1">
@@ -2221,6 +2317,16 @@ function openExternalUrl(url: string) {
</p>
</div>
</div>
<template v-if="form.db_type === 'h2'">
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" placeholder="sa" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.password") }}</Label>
<Input v-model="form.password" type="password" class="col-span-3" />
</div>
</template>
</template>
<!-- Redis: host, port, user, password, ssl -->
@@ -3267,17 +3373,7 @@ function openExternalUrl(url: string) {
<Button variant="outline" class="shrink-0" :disabled="isTesting || isSaving" @click="testConnection">
{{ isTesting ? t("connection.testing") : t("connection.test") }}
</Button>
<Button
class="shrink-0"
@click="save"
:disabled="
isSaving ||
(!form.host &&
!(mongoUseUrl && form.connection_string) &&
!(form.db_type === 'jdbc' && form.connection_string) &&
!connectionUrlInput.trim())
"
>
<Button class="shrink-0" @click="save" :disabled="isSaving || !hasRequiredConnectionTarget">
{{
isSaving
? t("common.loading")
+3
View File
@@ -111,6 +111,9 @@ export default {
type: "Type",
host: "Host",
filePath: "File Path",
h2FileMode: "File",
h2TcpMode: "TCP",
h2FilePathRequired: "H2 file path cannot be empty",
createDuckDbFile: "Create DuckDB file",
memoryDatabasePathHint: "Use :memory: to create an in-memory SQLite or DuckDB database.",
sqliteExtensions: "SQLite Extensions",
+3
View File
@@ -111,6 +111,9 @@ export default {
type: "Tipo",
host: "Host",
filePath: "Ruta del archivo",
h2FileMode: "Archivo",
h2TcpMode: "TCP",
h2FilePathRequired: "La ruta del archivo H2 no puede estar vacía",
createDuckDbFile: "Crear archivo DuckDB",
user: "Usuario",
password: "Contraseña",
+3
View File
@@ -111,6 +111,9 @@ export default {
type: "Tipo",
host: "Host",
filePath: "Percorso File",
h2FileMode: "File",
h2TcpMode: "TCP",
h2FilePathRequired: "Il percorso del file H2 non può essere vuoto",
createDuckDbFile: "Crea file DuckDB",
memoryDatabasePathHint: "Usa :memory: per creare un database SQLite o DuckDB in memoria.",
sqliteExtensions: "Estensioni SQLite",
+3
View File
@@ -111,6 +111,9 @@ export default {
type: "Tipo",
host: "Host",
filePath: "Caminho do Arquivo",
h2FileMode: "Arquivo",
h2TcpMode: "TCP",
h2FilePathRequired: "O caminho do arquivo H2 não pode ficar vazio",
createDuckDbFile: "Criar arquivo DuckDB",
memoryDatabasePathHint: "Use :memory: para criar um banco de dados SQLite ou DuckDB na memória.",
sqliteExtensions: "Extensões SQLite",
+3
View File
@@ -110,6 +110,9 @@ export default {
type: "类型",
host: "主机",
filePath: "文件路径",
h2FileMode: "文件",
h2TcpMode: "TCP",
h2FilePathRequired: "H2 文件路径不能为空",
createDuckDbFile: "新建 DuckDB 文件",
memoryDatabasePathHint: "输入 :memory: 可创建 SQLite 或 DuckDB 内存数据库。",
sqliteExtensions: "SQLite 扩展库",
+3
View File
@@ -110,6 +110,9 @@ export default {
type: "類型",
host: "主機",
filePath: "檔案路徑",
h2FileMode: "檔案",
h2TcpMode: "TCP",
h2FilePathRequired: "H2 檔案路徑不能為空",
createDuckDbFile: "建立 DuckDB 檔案",
memoryDatabasePathHint: "輸入 :memory: 可建立 SQLite 或 DuckDB 記憶體資料庫。",
sqliteExtensions: "SQLite 擴充庫",
@@ -20,7 +20,7 @@ export function connectionDriverLabel(connection?: Pick<ConnectionConfig, "db_ty
export function connectionEndpointLabel(connection?: ConnectionPresentationConfig): string {
if (!connection) return "";
if (LOCAL_DATABASE_TYPES.has(connection.db_type)) {
if (LOCAL_DATABASE_TYPES.has(connection.db_type) || (connection.db_type === "h2" && connection.port === 0)) {
return connection.host || connection.database || "local";
}
if (connection.host && connection.port) return `${connection.host}:${connection.port}`;
@@ -51,7 +51,7 @@ function redactConnectionHost(host: string): string {
export function connectionRedactedEndpointLabel(connection?: ConnectionPresentationConfig): string {
if (!connection) return "";
if (LOCAL_DATABASE_TYPES.has(connection.db_type)) {
if (LOCAL_DATABASE_TYPES.has(connection.db_type) || (connection.db_type === "h2" && connection.port === 0)) {
return connectionEndpointLabel(connection);
}
@@ -66,7 +66,13 @@ export function connectionRedactedEndpointLabel(connection?: ConnectionPresentat
export function connectionRedactedNameLabel(connection?: ConnectionNamePresentationConfig): string {
const name = connection?.name.trim() || "";
if (!connection || !name || LOCAL_DATABASE_TYPES.has(connection.db_type)) return name;
if (
!connection ||
!name ||
LOCAL_DATABASE_TYPES.has(connection.db_type) ||
(connection.db_type === "h2" && connection.port === 0)
)
return name;
const host = connection.host.trim();
if (!host) return name;
+31
View File
@@ -0,0 +1,31 @@
import type { ConnectionConfig } from "@/types/database";
export type H2ConnectionMode = "file" | "tcp";
const H2_FILE_PREFIX = "jdbc:h2:file:";
export function isH2FileJdbcUrl(value: string | undefined | null): boolean {
return (value || "").trim().toLowerCase().startsWith(H2_FILE_PREFIX);
}
export function h2FilePathFromJdbcUrl(value: string | undefined | null): string {
const trimmed = (value || "").trim();
if (!isH2FileJdbcUrl(trimmed)) return "";
return trimmed.slice(H2_FILE_PREFIX.length).split(";")[0] || "";
}
export function h2JdbcFileBasePath(path: string): string {
const trimmed = path.trim();
if (/\.mv\.db$/i.test(trimmed)) return trimmed.slice(0, -".mv.db".length);
if (/\.h2\.db$/i.test(trimmed)) return trimmed.slice(0, -".h2.db".length);
return trimmed;
}
export function h2FileJdbcUrl(path: string): string {
return `${H2_FILE_PREFIX}${h2JdbcFileBasePath(path)};AUTO_SERVER=TRUE`;
}
export function h2ConnectionModeForConfig(config: Pick<ConnectionConfig, "db_type" | "connection_string" | "port">) {
if (config.db_type !== "h2") return "tcp";
return isH2FileJdbcUrl(config.connection_string) || config.port === 0 ? "file" : "tcp";
}
+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()
}
@@ -126,6 +126,7 @@ fn skips_tcp_probe_for_local_file_plugin_and_agent_types() {
assert!(skips_tcp_probe(&DatabaseType::DuckDb));
assert!(skips_tcp_probe(&DatabaseType::Jdbc));
assert!(skips_tcp_probe(&DatabaseType::Access));
assert!(skips_tcp_probe(&DatabaseType::H2));
assert!(skips_tcp_probe(&DatabaseType::Trino));
assert!(skips_tcp_probe(&DatabaseType::Oracle));
assert!(skips_tcp_probe(&DatabaseType::Tdengine));
+27
View File
@@ -0,0 +1,27 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
h2ConnectionModeForConfig,
h2FileJdbcUrl,
h2FilePathFromJdbcUrl,
h2JdbcFileBasePath,
} from "../../apps/desktop/src/lib/h2Connection.ts";
test("H2 file JDBC URL strips database file suffixes", () => {
assert.equal(h2JdbcFileBasePath("/data/app.mv.db"), "/data/app");
assert.equal(h2JdbcFileBasePath("/data/app.h2.db"), "/data/app");
assert.equal(h2FileJdbcUrl("/data/app.mv.db"), "jdbc:h2:file:/data/app;AUTO_SERVER=TRUE");
});
test("H2 file path is parsed from JDBC URL with options", () => {
assert.equal(h2FilePathFromJdbcUrl("jdbc:h2:file:/data/app;AUTO_SERVER=TRUE"), "/data/app");
});
test("H2 connection mode preserves TCP connections unless file mode is explicit", () => {
assert.equal(h2ConnectionModeForConfig({ db_type: "h2", port: 9092, connection_string: undefined }), "tcp");
assert.equal(h2ConnectionModeForConfig({ db_type: "h2", port: 0, connection_string: undefined }), "file");
assert.equal(
h2ConnectionModeForConfig({ db_type: "h2", port: 9092, connection_string: "jdbc:h2:file:/data/app" }),
"file",
);
});
+14 -4
View File
@@ -47,6 +47,7 @@ function formatQueryToolResult(result: QueryResult, title?: string) {
export const DBX_CONNECTION_TYPE_DESCRIPTION =
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access";
const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]);
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
@@ -171,7 +172,10 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
name: z.string().describe("Connection name"),
db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION),
host: z.string().describe("Database host"),
port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
port: z
.number()
.optional()
.describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
username: z.string().default("").describe("Username"),
password: z.string().default("").describe("Password"),
database: z.string().optional().describe("Default database name"),
@@ -180,9 +184,15 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
async ({ name, db_type, host, port, username, password, database, ssl }) => {
const existing = await backend.findConnection(name);
if (existing) return text(`Connection "${name}" already exists.`);
const FILE_BASED_TYPES = new Set(["sqlite", "duckdb", "access"]);
const DEFAULT_PORTS: Record<string, number> = { kwdb: 26257, rqlite: 4001, tdengine: 6041, iotdb: 6667, xugu: 5138 };
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_BASED_TYPES.has(db_type) ? 0 : undefined);
const DEFAULT_PORTS: Record<string, number> = {
kwdb: 26257,
rqlite: 4001,
tdengine: 6041,
iotdb: 6667,
xugu: 5138,
};
const resolvedPort =
port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined);
if (resolvedPort === undefined) return text("Port is required for this database type.");
const config = await backend.addConnection({
name,
+47 -3
View File
@@ -138,8 +138,22 @@ test("mongodb describe table returns inferred document fields", async () => {
...backend,
findConnection: async () => mongoConnection,
describeTable: async () => [
{ name: "_id", data_type: "object", is_nullable: false, column_default: null, is_primary_key: true, comment: null },
{ name: "name", data_type: "string", is_nullable: false, column_default: null, is_primary_key: false, comment: null },
{
name: "_id",
data_type: "object",
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
},
{
name: "name",
data_type: "string",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: null,
},
],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
@@ -159,7 +173,11 @@ test("mongodb execute query formats shell-style find results", async () => {
const scopedBackend: Backend = {
...backend,
findConnection: async () => mongoConnection,
executeQuery: async () => ({ columns: ["_id", "meta", "missing"], rows: [{ _id: "1", meta: { name: "demo" }, missing: null }], row_count: 1 }),
executeQuery: async () => ({
columns: ["_id", "meta", "missing"],
rows: [{ _id: "1", meta: { name: "demo" }, missing: null }],
row_count: 1,
}),
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
@@ -186,6 +204,32 @@ test("connection lookup failures include a stable MCP error code", async () => {
assert.match(result.content[0].text, /missing/);
});
test("add connection accepts H2 file paths without a port", async () => {
let added: Omit<ConnectionConfig, "id"> | undefined;
const scopedBackend: Backend = {
...backend,
findConnection: async () => undefined,
addConnection: async (config) => {
added = config;
return { id: "h2-file", ...config };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_add_connection.handler({
name: "h2-local",
db_type: "h2",
host: "/data/app.mv.db",
username: "sa",
password: "",
});
assert.equal(result.isError, undefined);
assert.equal(added?.db_type, "h2");
assert.equal(added?.host, "/data/app.mv.db");
assert.equal(added?.port, 0);
});
test("SQL safety failures include a stable MCP error code", async () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
+3 -3
View File
@@ -6,14 +6,14 @@ pub use dbx_core::agent_connection::{
oracle_auth_fallback_profiles, should_retry_oracle_with_10g_driver,
};
pub use dbx_core::connection::{
connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_url_for_endpoint, expand_tilde,
metadata_connection_config, probe_connection_endpoint, redacted_connection_url_for_endpoint, AppState, MysqlMode,
PoolKind,
connect_bare_metadata_pool, connect_mysql_metadata_pool, connection_url_for_endpoint, metadata_connection_config,
probe_connection_endpoint, redacted_connection_url_for_endpoint, AppState, MysqlMode, PoolKind,
};
use dbx_core::database_capabilities;
use dbx_core::db;
use dbx_core::db::agent_driver::AgentMethod;
use dbx_core::models::connection::{rewrite_jdbc_url_host, ConnectionConfig, DatabaseType};
pub use dbx_core::path_utils::expand_tilde;
fn mongo_legacy_connect_params(config: &ConnectionConfig, host: &str, port: u16) -> serde_json::Value {
serde_json::json!({