From 336fbe094b734108f7f485e7e96312dff4af23a4 Mon Sep 17 00:00:00 2001 From: Andrew Date: Wed, 5 Aug 2026 11:17:01 +0800 Subject: [PATCH] feat(gaussdb): support multi-host connections --- .../connection/ConnectionDialog.vue | 57 ++++++++++++- .../src/components/sidebar/TreeItem.vue | 22 ++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../__tests__/connection/gaussdbHosts.spec.ts | 30 +++++++ .../lib/connection/connectionPresentation.ts | 45 ++++++++-- .../src/lib/connection/gaussdbHosts.ts | 51 +++++++++++ crates/dbx-core/src/connection.rs | 75 +++++++++++++--- crates/dbx-core/src/models/connection.rs | 85 ++++++++++++++++++- .../app-tests/connectionPresentation.test.ts | 7 ++ 16 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/connection/gaussdbHosts.spec.ts create mode 100644 apps/desktop/src/lib/connection/gaussdbHosts.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 526461d35..0ce047a16 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -34,6 +34,7 @@ import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnecti import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation"; +import { parseGaussdbHosts, serializeGaussdbHosts, type GaussdbHostEntry } from "@/lib/connection/gaussdbHosts"; import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection"; import { firstZooKeeperEndpoint, normalizeZooKeeperConnectString } from "@/lib/zookeeper/zookeeperConnection"; import { setZooKeeperAuthScheme, zooKeeperAuthScheme as resolveZooKeeperAuthScheme, type ZooKeeperAuthScheme } from "@/lib/zookeeper/zookeeperConnectionOptions"; @@ -476,6 +477,36 @@ const gaussdbQuoteStyle = computed({ }, }); +const gaussdbHostEntries = ref(parseGaussdbHosts(form.value.host, form.value.port)); + +watch( + () => form.value.db_type, + (dbType) => { + if (dbType === "gaussdb") { + gaussdbHostEntries.value = parseGaussdbHosts(form.value.host, form.value.port); + } + }, +); + +watch( + () => [form.value.host, form.value.port] as const, + ([host, port]) => { + if (form.value.db_type === "gaussdb") { + gaussdbHostEntries.value = parseGaussdbHosts(host, port); + } + }, +); + +function addGaussdbHostEntry() { + const lastPort = gaussdbHostEntries.value.length > 0 ? gaussdbHostEntries.value[gaussdbHostEntries.value.length - 1].port : 5432; + gaussdbHostEntries.value.push({ host: "", port: lastPort }); +} + +function removeGaussdbHostEntry(idx: number) { + if (gaussdbHostEntries.value.length <= 1) return; + gaussdbHostEntries.value.splice(idx, 1); +} + function resizeNoteTextarea() { const textarea = noteTextareaRef.value; if (!textarea) return; @@ -3227,6 +3258,11 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo throw new Error(t("connection.kingbaseDatabaseRequired")); } } + if (config.db_type === "gaussdb") { + const serialized = serializeGaussdbHosts(gaussdbHostEntries.value); + config.host = serialized.host; + config.port = serialized.port; + } if (isCloudflareD1Connection(config)) { normalizeCloudflareD1Connection(config); if (!hasCloudflareD1Credentials(config)) { @@ -6157,7 +6193,26 @@ function openExternalUrl(url: string) { -
+ + +
diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 68626ad97..f06481af0 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -361,6 +361,8 @@ type DetailTooltipRow = { label: string; value: string; multiline?: boolean; + /** When set, renders each value on its own line (e.g. one host per line) */ + values?: string[]; }; function cleanTooltipValue(value: string | number | null | undefined): string { @@ -376,7 +378,7 @@ function redactedConnectionString(value: string): string { } function hostForDisplay(host: string): string { - if (!host.includes(":") || host.startsWith("[") || host.includes("://")) return host; + if (!host.includes(":") || host.startsWith("[") || host.includes("://") || host.includes(",")) return host; return `[${host}]`; } @@ -410,10 +412,17 @@ const detailTooltip = computed(() => { const config = connectionStore.getConfig(node.connectionId); if (!config) return null; const hostLabel = isLocalFileConnection(config) ? t("connection.filePath") : t("connection.host"); + const hostValue = cleanTooltipValue(config.host); + const hostValues = hostValue.includes(",") + ? hostValue + .split(",") + .map((h) => h.trim()) + .filter(Boolean) + : []; const rows: DetailTooltipRow[] = [ { label: t("connection.name"), value: cleanTooltipValue(config.name) }, { label: "URL", value: connectionTooltipUrl(config), multiline: true }, - { label: hostLabel, value: cleanTooltipValue(config.host), multiline: isLocalFileConnection(config) }, + ...(hostValues.length > 0 ? [{ label: hostLabel, value: hostValues[0], values: hostValues } as DetailTooltipRow] : [{ label: hostLabel, value: hostValue, multiline: isLocalFileConnection(config) } as DetailTooltipRow]), { label: "Port", value: Number(config.port) > 0 ? String(config.port) : "" }, { label: t("connection.database"), value: cleanTooltipValue(config.database) }, { label: t("connection.user"), value: cleanTooltipValue(config.username) }, @@ -1220,8 +1229,13 @@ function onKeydown(event: KeyboardEvent) {
- {{ row.label }} - + {{ row.label }} + + {{ row.value }} {{ row.value }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index ac3d4e1d2..0fc63ed12 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -193,6 +193,7 @@ export default { notePlaceholder: "Do not store passwords in plaintext in notes", type: "Type", host: "Host", + addHost: "Add Host", filePath: "File Path", h2FileMode: "File", h2TcpMode: "TCP", @@ -2593,6 +2594,7 @@ export default { revoke: "Revoke", username: "Username", host: "Host", + addHost: "Add Host", changePassword: "Change Password", newPassword: "New password", lock: "Lock", @@ -4605,6 +4607,7 @@ export default { connType: "Connection type", connName: "Connection name", host: "Host", + addHost: "Add Host", port: "Port", copied: "Copied to clipboard", saveConfigPrompt: "Please enter config name:", @@ -6552,6 +6555,7 @@ export default { brokerEndpoints: "Broker endpoints", nodeId: "Node ID", host: "Host", + addHost: "Add Host", port: "Port", rack: "Rack", role: "Role", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 21957cd00..ca35be19f 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -195,6 +195,7 @@ export default withEnglishFallback({ notePlaceholder: "No guardes contraseñas en texto plano en las notas", type: "Tipo", host: "Host", + addHost: "Añadir Host", filePath: "Ruta del archivo", h2FileMode: "Archivo", h2TcpMode: "TCP", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index bd59988bb..c3a1f4ce1 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -194,6 +194,7 @@ export default withEnglishFallback({ notePlaceholder: "Non salvare password in chiaro nelle note", type: "Tipo", host: "Host", + addHost: "Aggiungi Host", filePath: "Percorso File", h2FileMode: "File", h2TcpMode: "TCP", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 55f2f6221..52c518f19 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -194,6 +194,7 @@ export default withEnglishFallback({ notePlaceholder: "メモにパスワードを平文で保存しないでください", type: "タイプ", host: "ホスト", + addHost: "ホストを追加", filePath: "ファイルパス", h2FileMode: "ファイル", h2TcpMode: "TCP", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index c1976ee4e..94b324177 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -189,6 +189,7 @@ export default withEnglishFallback({ notePlaceholder: "메모에 비밀번호를 평문으로 저장하지 마세요", type: "유형", host: "호스트", + addHost: "호스트 추가", filePath: "파일 경로", h2FileMode: "파일", h2TcpMode: "TCP", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 50cb93683..c682fc64d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -195,6 +195,7 @@ export default withEnglishFallback({ notePlaceholder: "Não salve senhas em texto simples nas observações", type: "Tipo", host: "Host", + addHost: "Adicionar Host", filePath: "Caminho do Arquivo", h2FileMode: "Arquivo", h2TcpMode: "TCP", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 59b090be0..ad586567a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -195,6 +195,7 @@ export default withEnglishFallback({ notePlaceholder: "请勿在备注中明文保存密码", type: "类型", host: "主机", + addHost: "添加主机", filePath: "文件路径", h2FileMode: "文件", h2TcpMode: "TCP", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index b4771b83d..9c9693196 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -195,6 +195,7 @@ export default withEnglishFallback({ notePlaceholder: "請勿在備註中以明文儲存密碼", type: "類型", host: "主機", + addHost: "新增主機", filePath: "檔案路徑", h2FileMode: "檔案", h2TcpMode: "TCP", diff --git a/apps/desktop/src/lib/__tests__/connection/gaussdbHosts.spec.ts b/apps/desktop/src/lib/__tests__/connection/gaussdbHosts.spec.ts new file mode 100644 index 000000000..e0a9bc645 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/gaussdbHosts.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseGaussdbHosts, serializeGaussdbHosts } from "@/lib/connection/gaussdbHosts"; + +describe("GaussDB hosts", () => { + it("keeps a single host and port in separate fields", () => { + expect(serializeGaussdbHosts([{ host: "db.example.com", port: 5433 }])).toEqual({ host: "db.example.com", port: 5433 }); + }); + + it("normalizes a legacy single host:port value", () => { + expect(parseGaussdbHosts("db.example.com:5433", 5432)).toEqual([{ host: "db.example.com", port: 5433 }]); + }); + + it("serializes multiple hosts with embedded ports", () => { + expect( + serializeGaussdbHosts([ + { host: "db1", port: 5432 }, + { host: "db2", port: 5433 }, + ]), + ).toEqual({ host: "db1:5432,db2:5433", port: 5432 }); + }); + + it("round-trips bracketed IPv6 endpoints", () => { + const entries = parseGaussdbHosts("[2001:db8::1]:5433,[2001:db8::2]:5434", 5432); + expect(entries).toEqual([ + { host: "2001:db8::1", port: 5433 }, + { host: "2001:db8::2", port: 5434 }, + ]); + expect(serializeGaussdbHosts(entries).host).toBe("[2001:db8::1]:5433,[2001:db8::2]:5434"); + }); +}); diff --git a/apps/desktop/src/lib/connection/connectionPresentation.ts b/apps/desktop/src/lib/connection/connectionPresentation.ts index f5a241109..9f832c0f9 100644 --- a/apps/desktop/src/lib/connection/connectionPresentation.ts +++ b/apps/desktop/src/lib/connection/connectionPresentation.ts @@ -1,5 +1,6 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { GAUSSDB_M_JDBC_DRIVER_PROFILE } from "@/lib/database/jdbcDialect"; +import { parseGaussdbHosts, serializeGaussdbHosts } from "@/lib/connection/gaussdbHosts"; type ConnectionPresentationConfig = Pick; type ConnectionNamePresentationConfig = ConnectionPresentationConfig & Pick; @@ -22,15 +23,46 @@ export function connectionEndpointLabel(connection?: ConnectionPresentationConfi 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}`; - return connection.host || connection.database || ""; + const endpoint = normalizedPresentationEndpoint(connection); + if (endpoint.host && endpoint.port) { + // Multi-host format: host1:port1,host2:port2 — already includes ports + if (endpoint.host.includes(",")) return endpoint.host; + const endpointHost = endpoint.host.includes(":") ? `[${endpoint.host}]` : endpoint.host; + return `${endpointHost}:${endpoint.port}`; + } + return endpoint.host || connection.database || ""; +} + +function normalizedPresentationEndpoint(connection: ConnectionPresentationConfig): { host: string; port: number } { + if (connection.db_type !== "gaussdb") return { host: connection.host, port: connection.port }; + return serializeGaussdbHosts(parseGaussdbHosts(connection.host, connection.port)); } function redactConnectionHost(host: string): string { const normalizedHost = host.trim(); if (!normalizedHost) return ""; - const unwrappedHost = normalizedHost.startsWith("[") && normalizedHost.endsWith("]") ? normalizedHost.slice(1, -1) : normalizedHost; + // Multi-host format: host1:port1,host2:port2 — redact each host separately + // and replace each embedded port with the redacted marker. + if (normalizedHost.includes(",")) { + return normalizedHost + .split(",") + .map((part) => { + const trimmed = part.trim(); + const colonIdx = trimmed.lastIndexOf(":"); + if (colonIdx > 0) { + return `${redactSingleHost(trimmed.slice(0, colonIdx))}:${REDACTED_PORT}`; + } + return redactSingleHost(trimmed); + }) + .join(","); + } + + return redactSingleHost(normalizedHost); +} + +function redactSingleHost(host: string): string { + const unwrappedHost = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; const separator = unwrappedHost.includes(":") ? ":" : "."; const segments = unwrappedHost.split(separator).filter(Boolean); @@ -52,8 +84,11 @@ export function connectionRedactedEndpointLabel(connection?: ConnectionPresentat return connectionEndpointLabel(connection); } - const redactedHost = connection.host ? redactConnectionHost(connection.host) : ""; - if (redactedHost && connection.port) { + const endpoint = normalizedPresentationEndpoint(connection); + const redactedHost = endpoint.host ? redactConnectionHost(endpoint.host) : ""; + if (redactedHost && endpoint.port) { + // Multi-host format already includes ports + if (redactedHost.includes(",")) return redactedHost; const endpointHost = redactedHost.includes(":") ? `[${redactedHost}]` : redactedHost; return `${endpointHost}:${REDACTED_PORT}`; } diff --git a/apps/desktop/src/lib/connection/gaussdbHosts.ts b/apps/desktop/src/lib/connection/gaussdbHosts.ts new file mode 100644 index 000000000..2eceef902 --- /dev/null +++ b/apps/desktop/src/lib/connection/gaussdbHosts.ts @@ -0,0 +1,51 @@ +export interface GaussdbHostEntry { + host: string; + port: number; +} + +const DEFAULT_GAUSSDB_PORT = 5432; + +function validPort(value: number, fallback: number): number { + return Number.isInteger(value) && value > 0 && value <= 65535 ? value : fallback || DEFAULT_GAUSSDB_PORT; +} + +function parseEndpoint(value: string, fallbackPort: number): GaussdbHostEntry { + const endpoint = value.trim(); + if (endpoint.startsWith("[")) { + const close = endpoint.indexOf("]"); + if (close > 0) { + const suffix = endpoint.slice(close + 1); + const parsedPort = suffix.startsWith(":") ? Number(suffix.slice(1)) : fallbackPort; + return { host: endpoint.slice(1, close), port: validPort(parsedPort, fallbackPort) }; + } + } + if ((endpoint.match(/:/g) ?? []).length === 1) { + const [host, rawPort] = endpoint.split(":"); + const parsedPort = Number(rawPort); + if (host && Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) return { host, port: parsedPort }; + } + return { host: endpoint, port: validPort(fallbackPort, DEFAULT_GAUSSDB_PORT) }; +} + +export function parseGaussdbHosts(host: string, port: number): GaussdbHostEntry[] { + const fallbackPort = validPort(port, DEFAULT_GAUSSDB_PORT); + if (!host.trim()) return [{ host: "127.0.0.1", port: fallbackPort }]; + const entries = host + .split(",") + .map((part) => parseEndpoint(part, fallbackPort)) + .filter((entry) => entry.host); + return entries.length ? entries : [{ host: "127.0.0.1", port: fallbackPort }]; +} + +function formatEndpoint(entry: GaussdbHostEntry): string { + const host = entry.host.trim(); + const endpointHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `${endpointHost}:${validPort(entry.port, DEFAULT_GAUSSDB_PORT)}`; +} + +export function serializeGaussdbHosts(entries: readonly GaussdbHostEntry[]): { host: string; port: number } { + const normalized = entries.map((entry) => ({ host: entry.host.trim(), port: validPort(entry.port, DEFAULT_GAUSSDB_PORT) })).filter((entry) => entry.host); + if (!normalized.length) return { host: "", port: DEFAULT_GAUSSDB_PORT }; + if (normalized.length === 1) return normalized[0]!; + return { host: normalized.map(formatEndpoint).join(","), port: normalized[0]!.port }; +} diff --git a/crates/dbx-core/src/connection.rs b/crates/dbx-core/src/connection.rs index 5dc075d28..35e609821 100644 --- a/crates/dbx-core/src/connection.rs +++ b/crates/dbx-core/src/connection.rs @@ -4695,7 +4695,51 @@ pub async fn probe_connection_endpoint(config: &ConnectionConfig, host: &str, po return Ok(()); } let timeout = std::time::Duration::from_secs(config.effective_connect_timeout_secs()); - db::probe_tcp_endpoint(&format!("{:?}", config.db_type), host, port, timeout).await + + let entries = connection_probe_endpoints(host, port); + + if entries.is_empty() { + return Err("no host entries to probe".to_string()); + } + + // Probe each node sequentially; return success on the first reachable node. + // This matches the failover semantics of the real connection path. + let mut last_error = String::new(); + for (entry_host, entry_port) in &entries { + match db::probe_tcp_endpoint(&format!("{:?}", config.db_type), entry_host, *entry_port, timeout).await { + Ok(()) => return Ok(()), + Err(e) => last_error = e, + } + } + Err(last_error) +} + +fn connection_probe_endpoints(host: &str, default_port: u16) -> Vec<(String, u16)> { + host.split(',').filter_map(|part| parse_connection_probe_endpoint(part.trim(), default_port)).collect() +} + +fn parse_connection_probe_endpoint(endpoint: &str, default_port: u16) -> Option<(String, u16)> { + if endpoint.is_empty() { + return None; + } + if let Some(rest) = endpoint.strip_prefix('[') { + let close = rest.find(']')?; + let host = rest[..close].to_string(); + let port = rest + .get(close + 1..) + .and_then(|suffix| suffix.strip_prefix(':')) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default_port); + return Some((host, port)); + } + if endpoint.matches(':').count() == 1 { + if let Some((host, raw_port)) = endpoint.rsplit_once(':') { + if let Ok(port) = raw_port.parse::() { + return Some((host.to_string(), port)); + } + } + } + Some((endpoint.to_string(), default_port)) } fn validate_h2_file_connection(config: &ConnectionConfig) -> Result<(), String> { @@ -4782,16 +4826,16 @@ async fn detect_ob_oracle_mode(config: &ConnectionConfig, pool: &db::mysql::MySq #[cfg(test)] mod tests { use super::{ - agent_connect_timeout, connection_remote_endpoint, connection_url_for_endpoint, database_connection_config, - database_connection_config_with_catalog, gaussdb_identifier_quote_from_query_result, - gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, metadata_connection_config, - mysql_metadata_fallback_url, mysql_pool_setup_queries, oceanbase_mysql_query_timeout_sql, - oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, redacted_connection_url_for_endpoint, - redis_sentinel_transport_id, redis_sentinel_transport_prefix, sqlserver_legacy_agent_config, - sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, task_client_session_id, - upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe, validate_connection_url_params, - validate_h2_database_path, AppState, MysqlMode, PoolKind, GAUSSDB_M_JDBC_DRIVER_CLASS, - GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, + agent_connect_timeout, connection_probe_endpoints, connection_remote_endpoint, connection_url_for_endpoint, + database_connection_config, database_connection_config_with_catalog, + gaussdb_identifier_quote_from_query_result, gaussdb_m_jdbc_config_for_endpoint, gaussdb_uses_m_jdbc_driver, + metadata_connection_config, mysql_metadata_fallback_url, mysql_pool_setup_queries, + oceanbase_mysql_query_timeout_sql, oceanbase_mysql_setup_queries, prestosql_jdbc_config_for_endpoint, + redacted_connection_url_for_endpoint, redis_sentinel_transport_id, redis_sentinel_transport_prefix, + sqlserver_legacy_agent_config, sqlserver_legacy_driver_error, sqlserver_uses_legacy_driver, + task_client_session_id, upsert_connection_url_param, uses_bare_mysql_pool, uses_tcp_probe, + validate_connection_url_params, validate_h2_database_path, AppState, MysqlMode, PoolKind, + GAUSSDB_M_JDBC_DRIVER_CLASS, GAUSSDB_M_JDBC_DRIVER_PROFILE, PRESTOSQL_JDBC_DRIVER_CLASS, }; use crate::agent_connection::{ agent_connect_params, mongo_legacy_error_with_auth_hint, mongo_uses_legacy_driver, @@ -5834,6 +5878,15 @@ mod tests { ); } + #[test] + fn gaussdb_probe_endpoints_parse_legacy_and_ipv6_hosts() { + assert_eq!(connection_probe_endpoints("db.example.com:5433", 5432), vec![("db.example.com".to_string(), 5433)]); + assert_eq!( + connection_probe_endpoints("[2001:db8::1]:5433,[2001:db8::2]:5434", 5432), + vec![("2001:db8::1".to_string(), 5433), ("2001:db8::2".to_string(), 5434)] + ); + } + #[test] fn kwdb_endpoint_url_uses_postgres_scheme_for_native_driver() { let mut config = mysql_config(None); diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 5396d28f3..d18652d96 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -992,7 +992,11 @@ impl ConnectionConfig { } DatabaseType::Postgres | DatabaseType::Redshift => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; - format!("postgres://{host}:{port}{db_part}{suffix}") + if is_multi_host(raw_host) { + format!("postgres://{raw_host}{db_part}{suffix}") + } else { + 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), @@ -1038,7 +1042,14 @@ impl ConnectionConfig { DatabaseType::Uxdb => format!("uxdb://{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::Gaussdb => { + if is_multi_host(raw_host) { + format!("gaussdb://{raw_host}{db_part}") + } else { + let (gaussdb_host, gaussdb_port) = gaussdb_single_host_port(raw_host, port); + format!("gaussdb://{gaussdb_host}:{gaussdb_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}"), @@ -1134,7 +1145,12 @@ impl ConnectionConfig { } DatabaseType::Postgres | DatabaseType::Redshift => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; - format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password) + if is_multi_host(raw_host) { + // Multi-host: host1:port1,host2:port2 — each host already has its port embedded + format!("postgres://{}:{}@{raw_host}{db_part}{suffix}", username, password) + } else { + 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), @@ -1202,7 +1218,13 @@ impl ConnectionConfig { format!("goldendb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Gaussdb => { - format!("gaussdb://{}:{}@{host}:{port}{db_part}", username, password) + if is_multi_host(raw_host) { + // Multi-host: host1:port1,host2:port2 — each host already has its port embedded + format!("gaussdb://{}:{}@{raw_host}{db_part}", username, password) + } else { + let (gaussdb_host, gaussdb_port) = gaussdb_single_host_port(raw_host, port); + format!("gaussdb://{}:{}@{gaussdb_host}:{gaussdb_port}{db_part}", username, password) + } } DatabaseType::Kwdb => { format!("kwdb://{}:{}@{host}:{port}{db_part}", username, password) @@ -2167,6 +2189,39 @@ fn bracket_ipv6(host: &str) -> String { } } +/// Returns `true` when `host` contains two or more comma-separated entries +/// where each entry already embeds its own `:port` suffix. +/// +/// A single entry such as `db.example.com:5432` is **not** multi-host — +/// the scalar `port` parameter must be appended separately. +fn is_multi_host(host: &str) -> bool { + let count = host.split(',').count(); + count >= 2 +} + +fn gaussdb_single_host_port(host: &str, default_port: u16) -> (String, u16) { + let host = host.trim(); + if let Some(close) = host.strip_prefix('[').and_then(|value| value.find(']').map(|index| index + 1)) { + let bracketed_host = &host[..=close]; + if let Some(port) = host + .get(close + 1..) + .and_then(|suffix| suffix.strip_prefix(':')) + .and_then(|value| value.parse::().ok()) + { + return (bracketed_host.to_string(), port); + } + return (bracketed_host.to_string(), default_port); + } + if host.matches(':').count() == 1 { + if let Some((single_host, raw_port)) = host.rsplit_once(':') { + if let Ok(port) = raw_port.parse::() { + return (bracket_ipv6(single_host), port); + } + } + } + (bracket_ipv6(host), default_port) +} + #[cfg(test)] mod tests { use super::{ @@ -3210,6 +3265,28 @@ mod tests { assert_eq!(config.connection_url(), "gaussdb://gaussdb:secret@10.1.2.3:2883/postgres"); } + #[test] + fn gaussdb_url_normalizes_legacy_single_host_port() { + let mut config = mysql_config("gaussdb", "secret", None); + config.db_type = DatabaseType::Gaussdb; + config.host = "db.example.com:5433".to_string(); + config.port = 5432; + + assert_eq!(config.connection_url(), "gaussdb://gaussdb:secret@db.example.com:5433/postgres"); + assert_eq!(config.redacted_connection_url(), "gaussdb://db.example.com:5433/postgres"); + } + + #[test] + fn gaussdb_url_supports_single_and_multi_host_ipv6() { + let mut config = mysql_config("gaussdb", "secret", None); + config.db_type = DatabaseType::Gaussdb; + config.host = "[2001:db8::1]:5433".to_string(); + assert_eq!(config.connection_url(), "gaussdb://gaussdb:secret@[2001:db8::1]:5433/postgres"); + + config.host = "[2001:db8::1]:5433,[2001:db8::2]:5434".to_string(); + assert_eq!(config.connection_url(), "gaussdb://gaussdb:secret@[2001:db8::1]:5433,[2001:db8::2]:5434/postgres"); + } + #[test] fn kwdb_url_defaults_to_defaultdb_database() { let mut config = mysql_config("root", "secret", None); diff --git a/packages/app-tests/connectionPresentation.test.ts b/packages/app-tests/connectionPresentation.test.ts index a79082a32..a98839699 100644 --- a/packages/app-tests/connectionPresentation.test.ts +++ b/packages/app-tests/connectionPresentation.test.ts @@ -25,6 +25,13 @@ test("displays the configured GaussDB protocol in connection URLs", () => { assert.equal(connectionDisplayUrlScheme({ db_type: "gaussdb", driver_profile: "gaussdb-m" }), "jdbc:gaussdb"); }); +test("normalizes legacy single-host GaussDB endpoint labels", () => { + const connection = { ...baseConnection, db_type: "gaussdb" as const, host: "db.example.com:5433", port: 5432 }; + + assert.equal(connectionEndpointLabel(connection), "db.example.com:5433"); + assert.equal(connectionRedactedEndpointLabel(connection), "db.***.com:****"); +}); + test("builds a compact subtitle for duplicate connection names", () => { assert.equal(connectionDriverLabel(baseConnection), "TiDB"); assert.equal(connectionEndpointLabel(baseConnection), "127.0.0.1:4000");