feat(sidebar): copy connection info as url jdbc or dsn

This commit is contained in:
邹超 authored and GitHub committed 2026-09-11 16:40:11 +08:00
1 parent 16866293a3
commit f9f9a28edf
13 files changed
+887 -1

No files matched your search

@@ -61,6 +61,7 @@ import {
Settings2,
GitBranch,
Sparkles,
Link2,
} from "@lucide/vue";
import type { ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore";
@@ -70,7 +71,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { useToast } from "@/composables/useToast";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/backend/api";
import type { ElasticsearchIndexMetadataKind } from "@/lib/backend/tauri";
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
@@ -192,6 +193,7 @@ import { formatSidebarTableCopyText, type FormatSidebarTableNamesOptions } from
import { supportsScheduledDatabaseBackup } from "@/lib/backup/scheduledDatabaseBackup";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { copyToClipboard } from "@/lib/common/clipboard";
import { buildConnectionUrlCopy, CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS, connectionUrlCopyFormats, type ConnectionUrlCopyFormat } from "@/lib/connection/connectionUrlBuilder";
import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory";
import { savedSqlClipboardFileIds, savedSqlPasteTargetForNode } from "@/lib/savedSql/savedSqlClipboard";
import { exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport";
@@ -2214,6 +2216,64 @@ function copyNameMenuItem(): ContextMenuItem {
return { label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value };
}
const CONNECTION_URL_COPY_LABEL_KEYS: Record<ConnectionUrlCopyFormat, string> = {
url: "contextMenu.copyConnectionUrl",
urlWithPassword: "contextMenu.copyConnectionUrlWithPassword",
jdbcUrl: "contextMenu.copyJdbcUrl",
jdbcUrlWithCredentials: "contextMenu.copyJdbcUrlWithCredentials",
hostPort: "contextMenu.copyHostPort",
dsn: "contextMenu.copyDsn",
dsnWithPassword: "contextMenu.copyDsnWithPassword",
psqlCommand: "contextMenu.copyPsqlCommand",
};
/**
* "Copy Connection Info" submenu for connection/database nodes: standard URL,
* JDBC URL, host:port, libpq DSN and psql command, trimmed to what the
* connection's dialect actually supports. The primary entries never embed the
* stored password; password-inclusive entries say so in their label and are
* gated behind a confirmation dialog. `databaseOverride` lets a database node
* copy the URL for that specific database instead of the default one.
*/
function connectionUrlCopyMenuItem(databaseOverride?: string): ContextMenuItem | null {
const node = activeNode.value;
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
if (!config) return null;
const formats = connectionUrlCopyFormats(config);
if (formats.length === 0) return null;
return {
label: t("contextMenu.copyConnectionInfo"),
icon: Link2,
children: formats.map((format) => ({
label: t(CONNECTION_URL_COPY_LABEL_KEYS[format]),
action: () => copyConnectionUrlFormat(config, format, databaseOverride),
})),
};
}
const showCopyConnectionSecretConfirm = shallowRef(false);
let pendingConnectionSecretCopy: { config: ConnectionConfig; format: ConnectionUrlCopyFormat; databaseOverride?: string } | null = null;
function copyConnectionUrlFormat(config: ConnectionConfig, format: ConnectionUrlCopyFormat, databaseOverride?: string) {
if (CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS.has(format)) {
pendingConnectionSecretCopy = { config, format, databaseOverride };
showCopyConnectionSecretConfirm.value = true;
return;
}
return performConnectionUrlCopy(config, format, databaseOverride);
}
async function performConnectionUrlCopy(config: ConnectionConfig, format: ConnectionUrlCopyFormat, databaseOverride?: string) {
const text = buildConnectionUrlCopy(config, format, { database: databaseOverride });
if (!text) return;
try {
await copyToClipboard(text);
toast(t("connection.copied"), 2000);
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
}
}
async function copyCustomTypeDdl() {
const node = createSidebarActionTarget(activeNode.value);
if (!node.connectionId || !node.database) return;
@@ -4653,6 +4713,19 @@ routeDangerDialog(showDeleteSavedSqlConfirm, () =>
}),
);
routeDangerDialog(showCopyConnectionSecretConfirm, () =>
dangerRequest({
title: t("contextMenu.copyPasswordConfirmTitle"),
message: t("contextMenu.copyPasswordConfirmMessage"),
confirmLabel: t("contextMenu.copyPasswordConfirmAction"),
confirm: () => {
const pending = pendingConnectionSecretCopy;
pendingConnectionSecretCopy = null;
if (pending) return performConnectionUrlCopy(pending.config, pending.format, pending.databaseOverride);
},
}),
);
routeDangerDialog(showEmptyTableConfirm, () =>
dangerRequest({
title: t("contextMenu.confirmEmptyTableTitle"),
@@ -5347,6 +5420,8 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
const connectionUrlCopyMenu = connectionUrlCopyMenuItem();
if (connectionUrlCopyMenu) items.push(connectionUrlCopyMenu);
items.push({ label: "", separator: true });
const supportsQueryActions = supportsConnectionQueryActions(currentDatabaseType());
const supportsAiContext = supportsAiAssistantContext(currentDatabaseType());
@@ -5565,6 +5640,8 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
items.unshift({ label: t("contextMenu.closeDatabaseConnection"), action: closeDatabaseConnection, icon: Unplug });
}
items.push(copyNameMenuItem());
const databaseUrlCopyMenu = connectionUrlCopyMenuItem(node.database || undefined);
if (databaseUrlCopyMenu) items.push(databaseUrlCopyMenu);
items.push({ label: "", separator: true });
if (canOpenObjectBrowser.value) {
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
+12
View File
@@ -3570,6 +3570,18 @@ export default withEnglishFallback({
schemaCommentPlaceholder: "Sxemin şərhini daxil edin...",
schemaCommentSaving: "Yadda saxlanılır...",
editSchemaCommentSuccess: '"{name}" sxeminin şərhi yeniləndi',
copyConnectionInfo: "Qoşulma məlumatını kopyala",
copyConnectionUrl: "Qoşulma URL-i",
copyConnectionUrlWithPassword: "Qoşulma URL-i (parol ilə)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL (istifadəçi adı və parol ilə)",
copyHostPort: "Host:port",
copyDsn: "DSN (key=value)",
copyDsnWithPassword: "DSN (parol ilə)",
copyPsqlCommand: "psql əmri",
copyPasswordConfirmTitle: "Parol ilə kopyala",
copyPasswordConfirmMessage: "Kopyalanan mətndə saxlanılan parol açıq şəkildə olacaq. Davam edilsin?",
copyPasswordConfirmAction: "Kopyala",
},
visibleDatabases: {
title: "Görünən verilənlər bazaları",
+12
View File
@@ -3229,6 +3229,18 @@ export default {
fullPath: "Full Path",
copyFinalProxyPort: "Copy Final Proxy Port",
finalProxyPortCopied: "Final proxy port {port} copied",
copyConnectionInfo: "Copy Connection Info",
copyConnectionUrl: "Connection URL",
copyConnectionUrlWithPassword: "Connection URL (with password)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL (with credentials)",
copyHostPort: "Host:Port",
copyDsn: "DSN (key=value)",
copyDsnWithPassword: "DSN (with password)",
copyPsqlCommand: "psql Command",
copyPasswordConfirmTitle: "Copy with Password",
copyPasswordConfirmMessage: "The copied text will contain the stored password in plain text. Continue?",
copyPasswordConfirmAction: "Copy",
setDefaultDatabase: "Set as Default Database",
clearDefaultDatabase: "Clear Default Database",
setDefaultSchema: "Set as Default Schema",
+12
View File
@@ -3451,6 +3451,18 @@ export default withEnglishFallback({
editObject: "Editar objeto",
createEvent: "Crear evento",
viewDependencies: "Ver dependencias",
copyConnectionInfo: "Copiar información de conexión",
copyConnectionUrl: "URL de conexión",
copyConnectionUrlWithPassword: "URL de conexión (con contraseña)",
copyJdbcUrl: "URL JDBC",
copyJdbcUrlWithCredentials: "URL JDBC (con usuario y contraseña)",
copyHostPort: "Host:puerto",
copyDsn: "DSN (key=value)",
copyDsnWithPassword: "DSN (con contraseña)",
copyPsqlCommand: "Comando psql",
copyPasswordConfirmTitle: "Copiar con contraseña",
copyPasswordConfirmMessage: "El texto copiado contendrá la contraseña guardada en texto sin formato. ¿Continuar?",
copyPasswordConfirmAction: "Copiar",
},
visibleDatabases: {
title: "Bases de datos visibles",
+12
View File
@@ -3449,6 +3449,18 @@ export default withEnglishFallback({
editObject: "Modifica oggetto",
createEvent: "Nuovo evento",
viewDependencies: "Visualizza dipendenze",
copyConnectionInfo: "Copia informazioni di connessione",
copyConnectionUrl: "URL di connessione",
copyConnectionUrlWithPassword: "URL di connessione (con password)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL (con nome utente e password)",
copyHostPort: "Host:porta",
copyDsn: "DSN (key=value)",
copyDsnWithPassword: "DSN (con password)",
copyPsqlCommand: "Comando psql",
copyPasswordConfirmTitle: "Copia con password",
copyPasswordConfirmMessage: "Il testo copiato conterrà la password salvata in chiaro. Continuare?",
copyPasswordConfirmAction: "Copia",
},
visibleDatabases: {
title: "Database Visibili",
+12
View File
@@ -3475,6 +3475,18 @@ export default withEnglishFallback({
editObject: "オブジェクトを編集",
createEvent: "イベントを作成",
viewDependencies: "依存関係を表示",
copyConnectionInfo: "接続情報をコピー",
copyConnectionUrl: "接続URL",
copyConnectionUrlWithPassword: "接続URL(パスワードを含む)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL(アカウントとパスワードを含む)",
copyHostPort: "ホスト:ポート",
copyDsn: "DSN(key=value)",
copyDsnWithPassword: "DSN(パスワードを含む)",
copyPsqlCommand: "psql コマンド",
copyPasswordConfirmTitle: "パスワードを含めてコピー",
copyPasswordConfirmMessage: "コピーされるテキストには保存済みのパスワードが平文で含まれます。続行しますか?",
copyPasswordConfirmAction: "コピー",
},
visibleDatabases: {
title: "表示するデータベース",
+12
View File
@@ -3328,6 +3328,18 @@ export default withEnglishFallback({
refreshDdlOnOpen: "열 때마다 새로 고침",
refreshDdlOnOpenHint: "켜면 열 때마다 데이터베이스에서 DDL을 다시 불러오고, 끄면 캐시된 데이터를 우선 사용합니다",
viewDependencies: "의존 관계 보기",
copyConnectionInfo: "연결 정보 복사",
copyConnectionUrl: "연결 URL",
copyConnectionUrlWithPassword: "연결 URL(비밀번호 포함)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL(계정 및 비밀번호 포함)",
copyHostPort: "호스트:포트",
copyDsn: "DSN(key=value)",
copyDsnWithPassword: "DSN(비밀번호 포함)",
copyPsqlCommand: "psql 명령어",
copyPasswordConfirmTitle: "비밀번호 포함 복사",
copyPasswordConfirmMessage: "복사되는 텍스트에 저장된 비밀번호가 평문으로 포함됩니다. 계속할까요?",
copyPasswordConfirmAction: "복사",
},
visibleDatabases: {
title: "표시할 데이터베이스",
+12
View File
@@ -3451,6 +3451,18 @@ export default withEnglishFallback({
editObject: "Editar objeto",
createEvent: "Criar evento",
viewDependencies: "Ver dependências",
copyConnectionInfo: "Copiar informações de conexão",
copyConnectionUrl: "URL de conexão",
copyConnectionUrlWithPassword: "URL de conexão (com senha)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL (com usuário e senha)",
copyHostPort: "Host:porta",
copyDsn: "DSN (key=value)",
copyDsnWithPassword: "DSN (com senha)",
copyPsqlCommand: "Comando psql",
copyPasswordConfirmTitle: "Copiar com senha",
copyPasswordConfirmMessage: "O texto copiado conterá a senha armazenada em texto simples. Continuar?",
copyPasswordConfirmAction: "Copiar",
},
visibleDatabases: {
title: "Bancos de dados visíveis",
+12
View File
@@ -3542,6 +3542,18 @@ export default withEnglishFallback({
schemaCommentPlaceholder: "Şema açıklamasını girin...",
schemaCommentSaving: "Kaydediliyor...",
editSchemaCommentSuccess: '"{name}" şemasının açıklaması güncellendi',
copyConnectionInfo: "Bağlantı bilgilerini kopyala",
copyConnectionUrl: "Bağlantı URL'si",
copyConnectionUrlWithPassword: "Bağlantı URL'si (parolalı)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL (kullanıcı adı ve parolalı)",
copyHostPort: "Ana bilgisayar:port",
copyDsn: "DSN (key=value)",
copyDsnWithPassword: "DSN (parolalı)",
copyPsqlCommand: "psql komutu",
copyPasswordConfirmTitle: "Parolayla kopyala",
copyPasswordConfirmMessage: "Kopyalanan metin depolanan parolayı düz metin olarak içerecek. Devam edilsin mi?",
copyPasswordConfirmAction: "Kopyala",
},
visibleDatabases: {
title: "Görünür Veritabanları",
+12
View File
@@ -3152,6 +3152,18 @@ export default withEnglishFallback({
fullPath: "完整路径",
copyFinalProxyPort: "复制最终代理端口",
finalProxyPortCopied: "最终代理端口 {port} 已复制",
copyConnectionInfo: "复制连接信息",
copyConnectionUrl: "连接 URL",
copyConnectionUrlWithPassword: "连接 URL(含密码)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL(含账号密码)",
copyHostPort: "主机:端口",
copyDsn: "DSN(key=value)",
copyDsnWithPassword: "DSN(含密码)",
copyPsqlCommand: "psql 命令",
copyPasswordConfirmTitle: "复制含密码的连接信息",
copyPasswordConfirmMessage: "复制的内容将包含明文密码,任何能读取剪贴板的程序或用户都可以看到。是否继续?",
copyPasswordConfirmAction: "继续复制",
setDefaultDatabase: "设为默认数据库",
clearDefaultDatabase: "取消默认数据库",
setDefaultSchema: "设为默认 Schema",
+12
View File
@@ -3448,6 +3448,18 @@ export default withEnglishFallback({
editObject: "編輯物件",
createEvent: "新建事件",
collapseAll: "全部摺疊",
copyConnectionInfo: "複製連線資訊",
copyConnectionUrl: "連線 URL",
copyConnectionUrlWithPassword: "連線 URL(含密碼)",
copyJdbcUrl: "JDBC URL",
copyJdbcUrlWithCredentials: "JDBC URL(含帳號密碼)",
copyHostPort: "主機:連接埠",
copyDsn: "DSN(key=value)",
copyDsnWithPassword: "DSN(含密碼)",
copyPsqlCommand: "psql 命令",
copyPasswordConfirmTitle: "複製含密碼的連線資訊",
copyPasswordConfirmMessage: "複製的內容將包含明文密碼,任何能存取剪貼簿的程式或使用者都可以看到。是否繼續?",
copyPasswordConfirmAction: "繼續複製",
},
visibleDatabases: {
title: "顯示資料庫",
@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import { buildConnectionUrlCopy, CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS, connectionSupportsUrlCopy, connectionUrlCopyFormats, type ConnectionUrlCopyConfig } from "@/lib/connection/connectionUrlBuilder";
function config(overrides: Partial<ConnectionUrlCopyConfig>): ConnectionUrlCopyConfig {
return {
db_type: "postgres",
host: "db.example.com",
port: 5432,
username: "app_user",
password: "secret",
database: "appdb",
url_params: "",
ssl: false,
...overrides,
};
}
describe("connectionUrlCopyFormats", () => {
it("lists all formats for a PostgreSQL connection with a stored password", () => {
expect(connectionUrlCopyFormats(config({}))).toEqual(["url", "urlWithPassword", "jdbcUrl", "jdbcUrlWithCredentials", "hostPort", "dsn", "dsnWithPassword", "psqlCommand"]);
});
it("drops every password-inclusive format when no password is stored", () => {
const formats = connectionUrlCopyFormats(config({ password: "" }));
for (const format of CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS) {
expect(formats).not.toContain(format);
}
expect(formats).toEqual(["url", "jdbcUrl", "hostPort", "dsn", "psqlCommand"]);
});
it("hides everything for file-based connections", () => {
expect(connectionSupportsUrlCopy(config({ db_type: "sqlite", host: "/data/app.db", port: 0 }))).toBe(false);
expect(connectionUrlCopyFormats(config({ db_type: "duckdb", host: "/data/app.duckdb", port: 0 }))).toEqual([]);
expect(connectionUrlCopyFormats(config({ db_type: "h2", host: "/data/app.mv.db", port: 0 }))).toEqual([]);
});
it("keeps H2 server mode available through its explicit connection string", () => {
const formats = connectionUrlCopyFormats(config({ db_type: "h2", host: "db.example.com", port: 9092, connection_string: "jdbc:h2:tcp://db.example.com:9092/~/test" }));
expect(formats).toEqual(["url", "hostPort"]);
});
it("hides URL copy for service-registry and message-queue connections", () => {
expect(connectionUrlCopyFormats(config({ db_type: "nacos", host: "nacos.example.com", port: 8848 }))).toEqual([]);
expect(connectionUrlCopyFormats(config({ db_type: "mq", host: "kafka.example.com", port: 9092 }))).toEqual([]);
});
it("offers only JDBC formats for hive-family connections", () => {
const formats = connectionUrlCopyFormats(config({ db_type: "hive", host: "hive.example.com", port: 10000, database: "dw" }));
expect(formats).toEqual(["jdbcUrl", "jdbcUrlWithCredentials", "hostPort"]);
});
});
describe("buildConnectionUrlCopy standard URL", () => {
it("builds a password-free PostgreSQL URL for the primary item", () => {
expect(buildConnectionUrlCopy(config({}), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb");
});
it("embeds credentials only in the explicit with-password variant", () => {
expect(buildConnectionUrlCopy(config({}), "urlWithPassword")).toBe("postgresql://app_user:secret@db.example.com:5432/appdb");
});
it("percent-encodes special characters in credentials and database", () => {
const text = buildConnectionUrlCopy(config({ username: "user@corp", password: "p@ss:w/ord#", database: "app db" }), "urlWithPassword");
expect(text).toBe("postgresql://user%40corp:p%40ss%3Aw%2Ford%23@db.example.com:5432/app%20db");
});
it("appends url_params and adds sslmode when ssl is enabled", () => {
expect(buildConnectionUrlCopy(config({ url_params: "application_name=svc" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?application_name=svc");
expect(buildConnectionUrlCopy(config({ ssl: true, url_params: "application_name=svc" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?application_name=svc&sslmode=require");
expect(buildConnectionUrlCopy(config({ ssl: true, url_params: "sslmode=verify-full" }), "url")).toBe("postgresql://app_user@db.example.com:5432/appdb?sslmode=verify-full");
});
it("honours the database override from a database tree node", () => {
expect(buildConnectionUrlCopy(config({}), "url", { database: "reporting" })).toBe("postgresql://app_user@db.example.com:5432/reporting");
});
it("uses rediss:// when TLS is enabled for Redis", () => {
const redis = config({ db_type: "redis", host: "cache.example.com", port: 6379, username: "", database: "0", ssl: true });
expect(buildConnectionUrlCopy(redis, "url")).toBe("rediss://cache.example.com:6379/0");
expect(buildConnectionUrlCopy(redis, "urlWithPassword")).toBe("rediss://:secret@cache.example.com:6379/0");
});
it("maps the MySQL family profiles to their own schemes", () => {
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "url")).toBe("mysql://app_user@db.example.com:3306/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, driver_profile: "mariadb" }), "urlWithPassword")).toBe("mariadb://app_user:secret@db.example.com:3306/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 2883, driver_profile: "oceanbase" }), "urlWithPassword")).toBe("oceanbase://app_user:secret@db.example.com:2883/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "doris", port: 9030 }), "urlWithPassword")).toBe("mysql://app_user:secret@db.example.com:9030/appdb");
});
it("keeps multi-host endpoints verbatim without an extra port suffix", () => {
const text = buildConnectionUrlCopy(config({ db_type: "gaussdb", host: "h1.example.com:5432,h2.example.com:5432" }), "urlWithPassword");
expect(text).toBe("postgresql://app_user:secret@h1.example.com:5432,h2.example.com:5432/appdb");
});
it("brackets bare IPv6 hosts", () => {
expect(buildConnectionUrlCopy(config({ host: "fe80::1" }), "urlWithPassword")).toBe("postgresql://app_user:secret@[fe80::1]:5432/appdb");
});
it("redacts explicit connection strings unless the with-password variant is requested", () => {
const jdbcConfig = config({ db_type: "jdbc", host: "", port: 0, username: "", password: "", database: undefined, connection_string: "jdbc:dremio:direct=drill.example.com:31010;user=analyst;password=s3cret" });
expect(buildConnectionUrlCopy(jdbcConfig, "url")).toBe("jdbc:dremio:direct=drill.example.com:31010;user=analyst;password=***");
expect(buildConnectionUrlCopy(jdbcConfig, "urlWithPassword")).toBe(jdbcConfig.connection_string);
// Credentials embedded in the explicit string count as a copyable secret
// even though no password field is stored.
expect(connectionUrlCopyFormats(jdbcConfig)).toEqual(["url", "urlWithPassword"]);
});
});
describe("buildConnectionUrlCopy JDBC URL", () => {
it("builds a generic PostgreSQL JDBC URL", () => {
expect(buildConnectionUrlCopy(config({}), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb");
expect(buildConnectionUrlCopy(config({}), "jdbcUrlWithCredentials")).toBe("jdbc:postgresql://db.example.com:5432/appdb?user=app_user&password=secret");
});
it("adds driver-specific ssl properties", () => {
expect(buildConnectionUrlCopy(config({ ssl: true }), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb?ssl=true");
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306, ssl: true }), "jdbcUrl")).toBe("jdbc:mysql://db.example.com:3306/appdb?sslMode=REQUIRED");
});
it("uses semicolon properties for SQL Server", () => {
const sqlserver = config({ db_type: "sqlserver", host: "sql.example.com", port: 1433, database: "appdb" });
expect(buildConnectionUrlCopy(sqlserver, "jdbcUrl")).toBe("jdbc:sqlserver://sql.example.com:1433;databaseName=appdb");
expect(buildConnectionUrlCopy(sqlserver, "jdbcUrlWithCredentials")).toBe("jdbc:sqlserver://sql.example.com:1433;databaseName=appdb;user=app_user;password=secret");
expect(buildConnectionUrlCopy({ ...sqlserver, ssl: true }, "jdbcUrl")).toBe("jdbc:sqlserver://sql.example.com:1433;databaseName=appdb;encrypt=true");
});
it("builds Oracle thin URLs for service names and SIDs", () => {
const oracle = config({ db_type: "oracle", host: "ora.example.com", port: 1521, database: "ORCLPDB1", username: "", password: "" });
expect(buildConnectionUrlCopy(oracle, "jdbcUrl")).toBe("jdbc:oracle:thin:@//ora.example.com:1521/ORCLPDB1");
expect(buildConnectionUrlCopy({ ...oracle, oracle_connection_type: "sid" }, "jdbcUrl")).toBe("jdbc:oracle:thin:@ora.example.com:1521:ORCLPDB1");
});
it("redacts explicit jdbc: strings for the plain item and keeps them verbatim for the credentials item", () => {
const jdbcConfig = config({ db_type: "jdbc", host: "", port: 0, username: "", password: "", database: undefined, connection_string: "jdbc:hive2://zk1:2181,zk2:2181/default;serviceDiscoveryMode=zooKeeper;password=s3cret" });
expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrl")).toBe("jdbc:hive2://zk1:2181,zk2:2181/default;serviceDiscoveryMode=zooKeeper;password=***");
expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrlWithCredentials")).toBe(jdbcConfig.connection_string);
});
it("passes explicit jdbc: strings without secrets through unchanged", () => {
const jdbcConfig = config({ db_type: "jdbc", host: "", port: 0, username: "", password: "", database: undefined, connection_string: "jdbc:hive2://zk1:2181,zk2:2181/default;serviceDiscoveryMode=zooKeeper" });
expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrl")).toBe(jdbcConfig.connection_string);
expect(buildConnectionUrlCopy(jdbcConfig, "jdbcUrlWithCredentials")).toBe(jdbcConfig.connection_string);
});
it("selects dialects by driver profile", () => {
expect(buildConnectionUrlCopy(config({ db_type: "gaussdb", driver_profile: "gaussdb-m" }), "jdbcUrl")).toBe("jdbc:gaussdb://db.example.com:5432/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "gaussdb" }), "jdbcUrl")).toBe("jdbc:postgresql://db.example.com:5432/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "gbase", port: 9088, driver_profile: "gbase8s" }), "jdbcUrl")).toBe("jdbc:gbasedbt-sqli://db.example.com:9088/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "gbase", port: 5258, driver_profile: "gbase8a" }), "jdbcUrl")).toBeNull();
expect(buildConnectionUrlCopy(config({ db_type: "tdengine", port: 6041 }), "jdbcUrl")).toBe("jdbc:TAOS-RS://db.example.com:6041/appdb");
expect(buildConnectionUrlCopy(config({ db_type: "tdengine", port: 6030 }), "jdbcUrl")).toBe("jdbc:TAOS://db.example.com:6030/appdb");
});
it("hides the JDBC item when the dialect is unknown", () => {
expect(buildConnectionUrlCopy(config({ db_type: "mongodb", port: 27017 }), "jdbcUrl")).toBeNull();
expect(connectionUrlCopyFormats(config({ db_type: "mongodb", port: 27017 }))).not.toContain("jdbcUrl");
});
it("uses the database override for JDBC URLs too", () => {
expect(buildConnectionUrlCopy(config({}), "jdbcUrl", { database: "analytics" })).toBe("jdbc:postgresql://db.example.com:5432/analytics");
});
});
describe("buildConnectionUrlCopy host:port, DSN and psql", () => {
it("copies host:port", () => {
expect(buildConnectionUrlCopy(config({}), "hostPort")).toBe("db.example.com:5432");
expect(buildConnectionUrlCopy(config({ port: 0 }), "hostPort")).toBe("db.example.com");
});
it("builds a libpq key=value DSN without the password by default", () => {
expect(buildConnectionUrlCopy(config({}), "dsn")).toBe("host=db.example.com port=5432 user=app_user dbname=appdb");
expect(buildConnectionUrlCopy(config({ ssl: true }), "dsn")).toBe("host=db.example.com port=5432 user=app_user dbname=appdb sslmode=require");
});
it("embeds the password only in the explicit with-password DSN", () => {
expect(buildConnectionUrlCopy(config({}), "dsnWithPassword")).toBe("host=db.example.com port=5432 user=app_user password=secret dbname=appdb");
expect(buildConnectionUrlCopy(config({ password: "has space" }), "dsnWithPassword")).toContain("password='has space'");
expect(buildConnectionUrlCopy(config({ password: "" }), "dsnWithPassword")).toBe(buildConnectionUrlCopy(config({ password: "" }), "dsn"));
});
it("builds a psql command without embedding the password", () => {
expect(buildConnectionUrlCopy(config({}), "psqlCommand")).toBe("psql -h db.example.com -p 5432 -U app_user -d appdb");
expect(buildConnectionUrlCopy(config({ database: undefined }), "psqlCommand", { database: "reporting" })).toBe("psql -h db.example.com -p 5432 -U app_user -d reporting");
});
it("restricts DSN and psql to the PostgreSQL wire family", () => {
expect(connectionUrlCopyFormats(config({ db_type: "mysql", port: 3306 }))).not.toContain("dsn");
expect(connectionUrlCopyFormats(config({ db_type: "mysql", port: 3306 }))).not.toContain("dsnWithPassword");
expect(connectionUrlCopyFormats(config({ db_type: "mysql", port: 3306 }))).not.toContain("psqlCommand");
expect(connectionUrlCopyFormats(config({ db_type: "redshift", port: 5439 }))).toContain("dsn");
expect(connectionUrlCopyFormats(config({ db_type: "gaussdb", driver_profile: "gaussdb-m" }))).not.toContain("dsn");
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "dsn")).toBeNull();
expect(buildConnectionUrlCopy(config({ db_type: "mysql", port: 3306 }), "dsnWithPassword")).toBeNull();
});
});
@@ -0,0 +1,494 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { GAUSSDB_M_JDBC_DRIVER_PROFILE } from "@/lib/database/jdbcDialect";
/**
* Builds copy-ready connection strings (standard URL / JDBC URL / libpq DSN /
* psql command) from a stored connection config. This is the inverse of
* `parseConnectionUrl()` in `connectionUrl.ts`: only formats that are actually
* usable in application code are produced, and unknown dialects are hidden
* from the menu instead of guessing a wrong URL.
*
* Password safety: the primary formats (`url`, `jdbcUrl`, `dsn`) never embed
* the stored password. Formats that do (`urlWithPassword`,
* `jdbcUrlWithCredentials`, `dsnWithPassword`) are only offered when a
* password is actually available, and the caller gates them behind an
* explicit confirmation.
*/
export type ConnectionUrlCopyFormat = "url" | "urlWithPassword" | "jdbcUrl" | "jdbcUrlWithCredentials" | "hostPort" | "dsn" | "dsnWithPassword" | "psqlCommand";
/** Formats whose output embeds the stored password; callers should confirm before copying. */
export const CONNECTION_URL_COPY_WITH_PASSWORD_FORMATS: ReadonlySet<ConnectionUrlCopyFormat> = new Set(["urlWithPassword", "jdbcUrlWithCredentials", "dsnWithPassword"]);
export type ConnectionUrlCopyConfig = Pick<ConnectionConfig, "db_type" | "host" | "port" | "username" | "password" | "database" | "url_params" | "ssl" | "connection_string"> & Partial<Pick<ConnectionConfig, "driver_profile" | "oracle_connection_type">>;
export interface ConnectionUrlCopyOptions {
/** Overrides `config.database` (e.g. the database tree node that was right-clicked). */
database?: string;
}
/**
* Types whose config shape does not map to a `scheme://user@host:port/db` URL
* (cloud resource paths, service registries, message queues, file paths).
*/
const URL_COPY_UNSUPPORTED_DB_TYPES = new Set<DatabaseType>(["bigquery", "spanner", "cloudflare-d1", "turso", "nacos", "mq", "mqtt", "hbase"]);
/**
* Standard (non-JDBC) URL schemes keyed by db_type. Values intentionally match
* what real client libraries accept, not necessarily DBX's own display scheme.
*/
const STANDARD_URL_SCHEMES: Partial<Record<DatabaseType, string>> = {
mysql: "mysql",
doris: "mysql",
starrocks: "mysql",
manticoresearch: "mysql",
goldendb: "mysql",
postgres: "postgresql",
redshift: "postgresql",
questdb: "postgresql",
kwdb: "postgresql",
yashandb: "postgresql",
highgo: "postgresql",
uxdb: "postgresql",
vastbase: "postgresql",
sundb: "postgresql",
gaussdb: "postgresql",
kingbase: "kingbase8",
sqlserver: "mssql",
oracle: "oracle",
clickhouse: "clickhouse",
mongodb: "mongodb",
redis: "redis",
dameng: "dm",
cassandra: "cassandra",
neo4j: "bolt",
etcd: "etcd",
zookeeper: "zookeeper",
oscar: "oscar",
xugu: "xugu",
iotdb: "iotdb",
iris: "iris",
trino: "trino",
databend: "databend",
};
/** Schemes that are plain HTTP(S) endpoints. */
const HTTP_URL_DB_TYPES = new Set<DatabaseType>(["elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]);
/**
* Generic `prefix://host:port/db` JDBC dialects. Dialects with a different URL
* shape (sqlserver, oracle, ...) are handled in `buildSpecialJdbcUrl()`.
*/
const JDBC_URL_PREFIXES: Partial<Record<DatabaseType, string>> = {
mysql: "jdbc:mysql",
doris: "jdbc:mysql",
starrocks: "jdbc:mysql",
manticoresearch: "jdbc:mysql",
goldendb: "jdbc:mysql",
postgres: "jdbc:postgresql",
redshift: "jdbc:redshift",
questdb: "jdbc:postgresql",
gaussdb: "jdbc:postgresql",
opengauss: "jdbc:opengauss",
kingbase: "jdbc:kingbase8",
highgo: "jdbc:highgo",
clickhouse: "jdbc:clickhouse",
dameng: "jdbc:dm",
hive: "jdbc:hive2",
kyuubi: "jdbc:hive2",
argo: "jdbc:hive2",
impala: "jdbc:impala",
trino: "jdbc:trino",
prestosql: "jdbc:presto",
db2: "jdbc:db2",
vertica: "jdbc:vertica",
firebird: "jdbc:firebirdsql",
databend: "jdbc:databend",
cassandra: "jdbc:cassandra",
neo4j: "jdbc:neo4j:bolt",
ignite: "jdbc:ignite:thin",
ignite3: "jdbc:ignite:thin",
iotdb: "jdbc:iotdb",
iris: "jdbc:IRIS",
oscar: "jdbc:oscar",
xugu: "jdbc:xugu",
};
/** PostgreSQL wire-protocol families that accept libpq DSN and psql. */
const POSTGRES_WIRE_DB_TYPES = new Set<DatabaseType>(["postgres", "redshift", "gaussdb", "questdb", "kwdb", "yashandb", "kingbase", "highgo", "uxdb", "vastbase", "sundb"]);
/** PG-wire families whose JDBC driver accepts the `ssl`/`sslmode` properties. */
const PG_JDBC_PREFIXES = new Set(["jdbc:postgresql", "jdbc:redshift", "jdbc:gaussdb", "jdbc:opengauss", "jdbc:kingbase8", "jdbc:highgo"]);
function normalizedDriverProfile(config: ConnectionUrlCopyConfig): string {
return config.driver_profile?.trim().toLowerCase() ?? "";
}
function isLocalFileUrlCopyConnection(config: ConnectionUrlCopyConfig): boolean {
const dbType = config.db_type;
return dbType === "sqlite" || dbType === "duckdb" || dbType === "access" || (dbType === "h2" && Number(config.port) === 0);
}
function explicitConnectionString(config: ConnectionUrlCopyConfig): string {
return config.connection_string?.trim() ?? "";
}
function looksLikeConnectionString(value: string): boolean {
return /^[a-z][a-z0-9+.-]*:/i.test(value);
}
export function connectionSupportsUrlCopy(config: ConnectionUrlCopyConfig | undefined): boolean {
if (!config) return false;
if (isLocalFileUrlCopyConnection(config)) return false;
if (URL_COPY_UNSUPPORTED_DB_TYPES.has(config.db_type)) return false;
return !!explicitConnectionString(config) || !!config.host?.trim();
}
function effectiveDatabase(config: ConnectionUrlCopyConfig, options?: ConnectionUrlCopyOptions): string {
return options?.database?.trim() || config.database?.trim() || "";
}
function queryHasParam(params: string, keys: string[]): boolean {
if (!params) return false;
const wanted = new Set(keys.map((key) => key.toLowerCase()));
return params
.split(/[&;]/)
.map((part) => part.split("=")[0]?.trim().toLowerCase())
.some((key) => !!key && wanted.has(key));
}
function joinNonEmpty(separator: string, parts: (string | false | undefined | null)[]): string {
return parts.filter((part): part is string => typeof part === "string" && part.length > 0).join(separator);
}
/** Brackets bare IPv6 literals; passes multi-host lists and full URLs through. */
function formatHostForUrl(host: string): string {
if (host.includes("://") || host.includes(",") || host.startsWith("[")) return host;
return host.includes(":") ? `[${host}]` : host;
}
function shouldAppendPort(config: ConnectionUrlCopyConfig): boolean {
const host = config.host?.trim() ?? "";
return Number(config.port) > 0 && !host.includes(",") && !host.includes("://");
}
function buildUserInfo(config: ConnectionUrlCopyConfig, includePassword: boolean): string {
const user = config.username?.trim() ?? "";
const password = config.password ?? "";
if (includePassword) {
if (user && password) return `${encodeURIComponent(user)}:${encodeURIComponent(password)}@`;
if (user) return `${encodeURIComponent(user)}@`;
if (password) return `:${encodeURIComponent(password)}@`;
return "";
}
return user ? `${encodeURIComponent(user)}@` : "";
}
/** Mirrors the sidebar tooltip redaction so shared strings never leak secrets. */
function redactConnectionStringSecrets(value: string): string {
return value.replace(/(:\/\/[^/\s:@?#;]+):([^@\s/?#;]+)@/g, "$1:***@").replace(/([?&;](?:password|pwd|pass|token|secret|key)=)[^&;]*/gi, "$1***");
}
function standardUrlScheme(config: ConnectionUrlCopyConfig): string | null {
const profile = normalizedDriverProfile(config);
switch (config.db_type) {
case "mysql":
if (profile === "oceanbase") return "oceanbase";
if (profile === "mariadb") return "mariadb";
return "mysql";
case "redis":
return config.ssl ? "rediss" : "redis";
case "gaussdb":
return profile === GAUSSDB_M_JDBC_DRIVER_PROFILE ? "jdbc:gaussdb" : "postgresql";
case "iris":
return profile === "cache" ? "cache" : "iris";
default: {
if (HTTP_URL_DB_TYPES.has(config.db_type)) return config.db_type === "dynamodb" ? "https" : config.ssl ? "https" : "http";
return STANDARD_URL_SCHEMES[config.db_type] ?? null;
}
}
}
function standardUrlQuery(config: ConnectionUrlCopyConfig, scheme: string): string {
const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, "");
const extras: string[] = [];
if (config.ssl) {
if ((scheme === "postgresql" || scheme === "kingbase8" || scheme === "jdbc:gaussdb") && !queryHasParam(raw, ["sslmode", "ssl"])) {
extras.push("sslmode=require");
} else if (scheme === "mysql" && !queryHasParam(raw, ["ssl-mode", "sslmode", "require_ssl", "usessl"])) {
extras.push("ssl-mode=REQUIRED");
} else if (scheme === "mongodb" && !queryHasParam(raw, ["tls", "ssl"])) {
extras.push("tls=true");
} else if (scheme === "clickhouse" && !queryHasParam(raw, ["secure", "ssl"])) {
extras.push("secure=1");
} else if (scheme === "cassandra" && !queryHasParam(raw, ["ssl"])) {
extras.push("ssl=true");
}
}
return joinNonEmpty("&", [raw, ...extras]);
}
function buildStandardUrl(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions & { includePassword: boolean }): string | null {
const explicit = explicitConnectionString(config);
const scheme = standardUrlScheme(config);
if (explicit && (!scheme || looksLikeConnectionString(explicit))) {
return options.includePassword ? explicit : redactConnectionStringSecrets(explicit);
}
if (!scheme) return null;
const host = config.host?.trim() ?? "";
if (!host) return null;
const database = effectiveDatabase(config, options);
const userInfo = buildUserInfo(config, options.includePassword);
const hostPart = formatHostForUrl(host);
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const path = database ? `/${encodeURIComponent(database)}` : "";
const query = standardUrlQuery(config, scheme);
return `${scheme}://${userInfo}${hostPart}${portPart}${path}${query ? `?${query}` : ""}`;
}
function jdbcPrefixFor(config: ConnectionUrlCopyConfig): string | null {
const profile = normalizedDriverProfile(config);
switch (config.db_type) {
case "mysql":
if (profile === "oceanbase") return "jdbc:oceanbase";
if (profile === "mariadb") return "jdbc:mariadb";
return "jdbc:mysql";
case "gaussdb":
return profile === GAUSSDB_M_JDBC_DRIVER_PROFILE ? "jdbc:gaussdb" : "jdbc:postgresql";
case "iris":
return profile === "cache" ? "jdbc:Cache" : "jdbc:IRIS";
case "gbase":
return profile === "gbase8s" ? "jdbc:gbasedbt-sqli" : null;
case "tdengine":
return Number(config.port) === 6030 ? "jdbc:TAOS" : "jdbc:TAOS-RS";
default:
return JDBC_URL_PREFIXES[config.db_type] ?? null;
}
}
function jdbcCredentialParams(config: ConnectionUrlCopyConfig, withCredentials: boolean): string[] {
if (!withCredentials) return [];
const params: string[] = [];
const user = config.username?.trim() ?? "";
if (user) params.push(`user=${encodeURIComponent(user)}`);
if (config.password) params.push(`password=${encodeURIComponent(config.password)}`);
return params;
}
/**
* Dialects whose JDBC URL is not `prefix://host:port/db?query`.
* Returns `undefined` when the type is not a special dialect, `null` when the
* dialect is special but cannot be built from this config.
*/
function buildSpecialJdbcUrl(config: ConnectionUrlCopyConfig, database: string, withCredentials: boolean): string | null | undefined {
const host = config.host?.trim() ?? "";
const rawParams = (config.url_params ?? "").trim();
const credentials = jdbcCredentialParams(config, withCredentials);
switch (config.db_type) {
case "sqlserver": {
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const props = joinNonEmpty(";", [database && `databaseName=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/&/g, ";"), config.ssl && !queryHasParam(rawParams, ["encrypt"]) && "encrypt=true"]);
return `jdbc:sqlserver://${formatHostForUrl(host)}${portPart}${props ? `;${props}` : ""}`;
}
case "oracle": {
const connectionType = config.oracle_connection_type ?? "service_name";
if (connectionType === "tns") {
const explicit = explicitConnectionString(config);
return explicit ? explicit : null;
}
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const query = joinNonEmpty("&", [...credentials, rawParams.replace(/^[?&]+/, "")]);
if (connectionType === "sid") {
return `jdbc:oracle:thin:@${formatHostForUrl(host)}${portPart}:${database}${query ? `?${query}` : ""}`;
}
return `jdbc:oracle:thin:@//${formatHostForUrl(host)}${portPart}/${database}${query ? `?${query}` : ""}`;
}
case "saphana": {
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const query = joinNonEmpty("&", [database && `databaseName=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "")]);
return `jdbc:sap://${formatHostForUrl(host)}${portPart}/${query ? `?${query}` : ""}`;
}
case "teradata": {
const props = joinNonEmpty(",", [Number(config.port) > 0 && `DBS_PORT=${config.port}`, database && `DATABASE=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/[;&]/g, ",")]);
return `jdbc:teradata://${formatHostForUrl(host)}${props ? `/${props}` : ""}`;
}
case "exasol": {
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const props = joinNonEmpty(";", [database && `schema=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "").replace(/&/g, ";")]);
return `jdbc:exa:${formatHostForUrl(host)}${portPart}${props ? `;${props}` : ""}`;
}
case "snowflake": {
if (!host) return null;
const query = joinNonEmpty("&", [database && `db=${database}`, ...credentials, rawParams.replace(/^[?&]+/, "")]);
return `jdbc:snowflake://${formatHostForUrl(host)}/${query ? `?${query}` : ""}`;
}
case "informix":
case "gbase": {
const prefix = config.db_type === "informix" ? "jdbc:informix-sqli" : jdbcPrefixFor(config);
if (!prefix) return config.db_type === "gbase" ? null : undefined;
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
// Informix-style URLs append properties after a second colon: /db:INFORMIXSERVER=x;user=y
const props = joinNonEmpty(";", [rawParams.replace(/^[?:&]+/, ""), ...credentials.map((param) => decodeURIComponent(param))]);
return `${prefix}://${formatHostForUrl(host)}${portPart}/${database}${props ? `:${props}` : ""}`;
}
default:
return undefined;
}
}
function jdbcQuery(config: ConnectionUrlCopyConfig, prefix: string, withCredentials: boolean): string {
const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, "");
const parts = [...jdbcCredentialParams(config, withCredentials)];
if (raw) parts.push(raw);
if (config.ssl) {
if (PG_JDBC_PREFIXES.has(prefix) && !queryHasParam(raw, ["ssl", "sslmode"])) {
parts.push("ssl=true");
} else if ((prefix === "jdbc:mysql" || prefix === "jdbc:mariadb" || prefix === "jdbc:oceanbase") && !queryHasParam(raw, ["usessl", "sslmode", "requiressl"])) {
parts.push("sslMode=REQUIRED");
} else if (prefix === "jdbc:clickhouse" && !queryHasParam(raw, ["ssl", "sslmode", "secure"])) {
parts.push("ssl=true");
}
}
return joinNonEmpty("&", parts);
}
function buildJdbcUrl(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions & { withCredentials: boolean }): string | null {
const explicit = explicitConnectionString(config);
if (explicit && /^jdbc:/i.test(explicit)) return options.withCredentials ? explicit : redactConnectionStringSecrets(explicit);
const host = config.host?.trim() ?? "";
if (!host) return null;
const database = effectiveDatabase(config, options);
const special = buildSpecialJdbcUrl(config, database, options.withCredentials);
if (special !== undefined) return special;
const prefix = jdbcPrefixFor(config);
if (!prefix) return null;
const portPart = shouldAppendPort(config) ? `:${config.port}` : "";
const path = database ? `/${encodeURIComponent(database)}` : "";
const query = jdbcQuery(config, prefix, options.withCredentials);
return `${prefix}://${formatHostForUrl(host)}${portPart}${path}${query ? `?${query}` : ""}`;
}
function buildHostPort(config: ConnectionUrlCopyConfig): string | null {
const host = config.host?.trim() ?? "";
if (!host) return null;
if (host.includes(",") || host.includes("://")) return host;
const hostPart = formatHostForUrl(host);
return Number(config.port) > 0 ? `${hostPart}:${config.port}` : hostPart;
}
function quoteDsnValue(value: string): string {
if (!/[\s'\\]/.test(value)) return value;
return `'${value.replace(/(['\\])/g, "\\$1")}'`;
}
function isPostgresWireFamily(config: ConnectionUrlCopyConfig): boolean {
if (!POSTGRES_WIRE_DB_TYPES.has(config.db_type)) return false;
if (config.db_type === "gaussdb" && normalizedDriverProfile(config) === GAUSSDB_M_JDBC_DRIVER_PROFILE) return false;
const explicit = explicitConnectionString(config);
return !(explicit && /^jdbc:/i.test(explicit));
}
/** libpq-style `host=... port=... user=... [password=...] dbname=...` DSN. */
function buildKeyvalueDsn(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions & { includePassword: boolean }): string | null {
const host = config.host?.trim() ?? "";
if (!host) return null;
const parts = [`host=${quoteDsnValue(host)}`];
if (shouldAppendPort(config)) parts.push(`port=${config.port}`);
const user = config.username?.trim() ?? "";
if (user) parts.push(`user=${quoteDsnValue(user)}`);
if (options.includePassword && config.password) parts.push(`password=${quoteDsnValue(config.password)}`);
const database = effectiveDatabase(config, options);
if (database) parts.push(`dbname=${quoteDsnValue(database)}`);
const raw = (config.url_params ?? "").trim().replace(/^[?&]+/, "");
for (const param of raw.split("&")) {
const [key, ...rest] = param.split("=");
if (!key.trim()) continue;
parts.push(`${key.trim()}=${quoteDsnValue(decodeURIComponent(rest.join("=")))}`);
}
if (config.ssl && !queryHasParam(raw, ["sslmode"])) parts.push("sslmode=require");
return parts.join(" ");
}
function buildPsqlCommand(config: ConnectionUrlCopyConfig, options: ConnectionUrlCopyOptions): string | null {
const host = config.host?.trim() ?? "";
if (!host) return null;
const args = ["psql", "-h", host];
if (shouldAppendPort(config)) args.push("-p", String(config.port));
const user = config.username?.trim() ?? "";
if (user) args.push("-U", user);
const database = effectiveDatabase(config, options);
if (database) args.push("-d", database);
return args.join(" ");
}
/**
* Whether a password-inclusive copy would actually carry a secret: either a
* stored password or an explicit connection string with embedded credentials.
*/
function hasCopyableSecret(config: ConnectionUrlCopyConfig): boolean {
if (config.password) return true;
const explicit = explicitConnectionString(config);
return !!explicit && redactConnectionStringSecrets(explicit) !== explicit;
}
/**
* Lists the copy formats available for a connection, deduplicating entries
* that would produce identical text (e.g. no stored password, or an explicit
* connection string that already is the JDBC URL). Password-inclusive formats
* are only offered when a secret is actually available, so the plain items
* are always safe to paste.
*/
export function connectionUrlCopyFormats(config: ConnectionUrlCopyConfig | undefined): ConnectionUrlCopyFormat[] {
if (!connectionSupportsUrlCopy(config)) return [];
const candidate = config as ConnectionUrlCopyConfig;
const formats: ConnectionUrlCopyFormat[] = [];
const hasSecret = hasCopyableSecret(candidate);
const url = buildStandardUrl(candidate, { includePassword: false });
const urlWithPassword = buildStandardUrl(candidate, { includePassword: true });
if (url) formats.push("url");
if (urlWithPassword && hasSecret && urlWithPassword !== url) formats.push("urlWithPassword");
const jdbcUrl = buildJdbcUrl(candidate, { withCredentials: false });
const jdbcUrlWithCredentials = buildJdbcUrl(candidate, { withCredentials: true });
if (jdbcUrl && jdbcUrl !== url) formats.push("jdbcUrl");
// Without a secret the "with credentials" URL would only add `user=` (or
// nothing), which is misleading next to its label — hide it in that case.
if (jdbcUrlWithCredentials && hasSecret && jdbcUrlWithCredentials !== jdbcUrl && jdbcUrlWithCredentials !== urlWithPassword && jdbcUrlWithCredentials !== url) formats.push("jdbcUrlWithCredentials");
if (buildHostPort(candidate)) formats.push("hostPort");
if (isPostgresWireFamily(candidate)) {
const dsn = buildKeyvalueDsn(candidate, { includePassword: false });
const dsnWithPassword = buildKeyvalueDsn(candidate, { includePassword: true });
if (dsn) formats.push("dsn");
if (dsnWithPassword && hasSecret && dsnWithPassword !== dsn) formats.push("dsnWithPassword");
if (buildPsqlCommand(candidate, {})) formats.push("psqlCommand");
}
return formats;
}
export function buildConnectionUrlCopy(config: ConnectionUrlCopyConfig | undefined, format: ConnectionUrlCopyFormat, options?: ConnectionUrlCopyOptions): string | null {
if (!connectionSupportsUrlCopy(config)) return null;
const candidate = config as ConnectionUrlCopyConfig;
switch (format) {
case "url":
return buildStandardUrl(candidate, { ...options, includePassword: false });
case "urlWithPassword":
return buildStandardUrl(candidate, { ...options, includePassword: true });
case "jdbcUrl":
return buildJdbcUrl(candidate, { ...options, withCredentials: false });
case "jdbcUrlWithCredentials":
return buildJdbcUrl(candidate, { ...options, withCredentials: true });
case "hostPort":
return buildHostPort(candidate);
case "dsn":
return isPostgresWireFamily(candidate) ? buildKeyvalueDsn(candidate, { ...options, includePassword: false }) : null;
case "dsnWithPassword":
return isPostgresWireFamily(candidate) ? buildKeyvalueDsn(candidate, { ...options, includePassword: true }) : null;
case "psqlCommand":
return isPostgresWireFamily(candidate) ? buildPsqlCommand(candidate, options ?? {}) : null;
default:
return null;
}
}