feat(sidebar): copy connection info as url jdbc or dsn
This commit is contained in:
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 });
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "表示するデータベース",
|
||||
|
||||
@@ -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: "표시할 데이터베이스",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user