feat(gaussdb): support multi-host connections
This commit is contained in:
parent
c0a6d6f2f9
commit
336fbe094b
|
|
@ -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<GaussdbIdentifierQuoteStyle>({
|
|||
},
|
||||
});
|
||||
|
||||
const gaussdbHostEntries = ref<GaussdbHostEntry[]>(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) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.db_type !== 'oracle' || form.oracle_connection_type !== 'tns'" class="grid grid-cols-4 items-center gap-4">
|
||||
<!-- GaussDB: multi-host dynamic list -->
|
||||
<template v-if="form.db_type === 'gaussdb'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelTopClass">{{ t("connection.host") }}</Label>
|
||||
<div class="col-span-3 space-y-2">
|
||||
<div v-for="(entry, idx) in gaussdbHostEntries" :key="idx" class="flex items-start gap-2">
|
||||
<Input v-model="entry.host" class="flex-1 min-w-0 break-all" placeholder="127.0.0.1" />
|
||||
<Input v-model.number="entry.port" type="number" class="w-24 shrink-0" />
|
||||
<Button type="button" variant="outline" size="icon" class="h-9 w-9 shrink-0 mt-0.5" :disabled="gaussdbHostEntries.length <= 1" @click="removeGaussdbHostEntry(idx)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" class="mt-1" @click="addGaussdbHostEntry">
|
||||
<Plus class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("connection.addHost") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="form.db_type !== 'oracle' || form.oracle_connection_type !== 'tns'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ form.db_type === "elasticsearch" && elasticsearchConnectionMode === "kibana" ? t("connection.elasticsearchKibanaHost") : t("connection.host") }}</Label>
|
||||
<Input v-model="form.host" class="col-span-2" />
|
||||
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div class="w-max min-w-40 max-w-[min(28rem,calc(100vw-24px))] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-lg">
|
||||
<div class="space-y-1">
|
||||
<div v-for="row in detailTooltip.rows" :key="row.label" class="grid grid-cols-[max-content_minmax(0,1fr)] gap-2 text-xs leading-5">
|
||||
<span class="text-muted-foreground">{{ row.label }}</span>
|
||||
<span v-if="row.multiline" class="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-foreground/90">
|
||||
<span class="text-muted-foreground shrink-0">{{ row.label }}</span>
|
||||
<template v-if="row.values">
|
||||
<div class="flex flex-col gap-0.5 font-mono text-foreground/90">
|
||||
<span v-for="(v, vi) in row.values" :key="vi" class="break-all">{{ v }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else-if="row.multiline" class="max-h-20 overflow-hidden whitespace-pre-wrap break-words text-foreground/90">
|
||||
{{ row.value }}
|
||||
</span>
|
||||
<span v-else class="truncate font-mono text-foreground/90" :title="row.value">{{ row.value }}</span>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export default withEnglishFallback({
|
|||
notePlaceholder: "メモにパスワードを平文で保存しないでください",
|
||||
type: "タイプ",
|
||||
host: "ホスト",
|
||||
addHost: "ホストを追加",
|
||||
filePath: "ファイルパス",
|
||||
h2FileMode: "ファイル",
|
||||
h2TcpMode: "TCP",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ export default withEnglishFallback({
|
|||
notePlaceholder: "메모에 비밀번호를 평문으로 저장하지 마세요",
|
||||
type: "유형",
|
||||
host: "호스트",
|
||||
addHost: "호스트 추가",
|
||||
filePath: "파일 경로",
|
||||
h2FileMode: "파일",
|
||||
h2TcpMode: "TCP",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export default withEnglishFallback({
|
|||
notePlaceholder: "请勿在备注中明文保存密码",
|
||||
type: "类型",
|
||||
host: "主机",
|
||||
addHost: "添加主机",
|
||||
filePath: "文件路径",
|
||||
h2FileMode: "文件",
|
||||
h2TcpMode: "TCP",
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export default withEnglishFallback({
|
|||
notePlaceholder: "請勿在備註中以明文儲存密碼",
|
||||
type: "類型",
|
||||
host: "主機",
|
||||
addHost: "新增主機",
|
||||
filePath: "檔案路徑",
|
||||
h2FileMode: "檔案",
|
||||
h2TcpMode: "TCP",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ConnectionConfig, "db_type" | "driver_profile" | "driver_label" | "host" | "port" | "database">;
|
||||
type ConnectionNamePresentationConfig = ConnectionPresentationConfig & Pick<ConnectionConfig, "name">;
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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::<u16>().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::<u16>() {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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::<u16>().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::<u16>() {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue