feat(oracle): add TNS connections and trigger editing

This commit is contained in:
t8y2 committed 2026-07-23 23:03:45 +08:00
1 parent 9a5edae219
commit 3b004abb1e
23 files changed
+980 -53

No files matched your search

@@ -29,6 +29,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connection/connectionPresentation";
import { h2ConnectionModeForConfig, h2FileJdbcUrlWithPath, h2FilePathFromJdbcUrl, isH2SplitJdbcUrl, type H2ConnectionMode } from "@/lib/database/h2Connection";
@@ -494,6 +495,7 @@ const selectedJdbcDriverPath = ref("");
const jdbcManualClasspathOpen = ref(false);
const connectionUrlInput = ref("");
const appliedConnectionUrlInput = ref("");
const oracleTnsAdminPath = ref("");
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
const h2ConnectionMode = ref<H2ConnectionMode>("file");
const dremioConnectionMode = ref<DremioConnectionMode>("legacy");
@@ -1764,6 +1766,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
}
if (!preserveConnectionFields) {
oracleTnsAdminPath.value = "";
form.value.port = profile.port;
setSqlServerPortExplicit(form.value, false);
form.value.username = profile.user;
@@ -1910,6 +1913,7 @@ watch(
visible_databases: config.visible_databases,
visible_schemas: config.visible_schemas,
};
oracleTnsAdminPath.value = parseOracleTnsConnectionString(config.connection_string)?.tnsAdmin || "";
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
@@ -1988,12 +1992,14 @@ watch(
);
const databaseLabel = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAlias");
if (form.value.db_type === "oracle") return t("connection.serviceName");
if (form.value.db_type === "influxdb" && influxDbVersion.value === "2") return "Bucket";
return t("connection.database");
});
const databasePlaceholder = computed(() => {
if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAliasPlaceholder");
if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired");
const fallback = defaultDatabaseForProfile();
if (!fallback) return t("connection.databasePlaceholder");
@@ -2485,7 +2491,7 @@ const zookeeperConnectString = computed({
form.value.connection_string = normalizeZooKeeperConnectString(value);
},
});
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value);
const canUseTransportLayers = computed(() => form.value.db_type !== "sqlite" && form.value.db_type !== "access" && !isCloudflareD1Connection(form.value) && !isH2FileMode.value && !(form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns"));
const shouldShowAgentDriverInstallHint = computed(() => showAgentDriverInstallHint(form.value.db_type, agentDrivers.value, form.value.driver_profile));
const h2DriverMissing = computed(() => form.value.db_type === "h2" && isH2FileMode.value && agentDrivers.value.find((d) => d.db_type === "h2")?.installed !== true);
const agentDriverFocus = computed<DriverStoreFocus>(() => ({ target: "driver", driver: agentDriverInstallKey(form.value.db_type, form.value.driver_profile) }));
@@ -2812,6 +2818,7 @@ function applyConnectionUrlToForm(input: string): boolean {
const parsed = parseConnectionUrl(input, selectedType.value);
form.value = applyParsedConnectionUrl(form.value, parsed);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || "";
selectedType.value = parsed.driverProfile;
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
mongoUseUrl.value = !!parsed.useMongoUrl;
@@ -2964,7 +2971,22 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
normalized.connect_timeout_secs = Number.isFinite(timeout) && timeout > 0 ? timeout : 5;
return { type: "ssh", ...normalized };
});
if (config.db_type === "oracle" && config.oracle_connection_type === "tns" && config.transport_layers.some((layer) => layer.enabled !== false)) {
throw new Error(t("connection.oracleTnsTransportUnsupported"));
}
validateTransportLayers(config);
if (config.db_type === "oracle" && config.oracle_connection_type === "tns") {
const alias = config.database?.trim() || "";
const tnsAdmin = normalizeOracleTnsAdminPath(oracleTnsAdminPath.value);
if (!alias) throw new Error(t("connection.oracleTnsAliasRequired"));
if (!tnsAdmin) throw new Error(t("connection.oracleTnsAdminRequired"));
config.database = alias;
config.connection_string = buildOracleTnsConnectionString(alias, tnsAdmin);
} else if (config.db_type === "oracle" && parseOracleTnsConnectionString(config.connection_string)) {
// Only clear DBX-generated TNS URLs when switching modes; preserve custom
// service, SID, and descriptor JDBC strings exactly as before.
config.connection_string = undefined;
}
const connectTimeout = Number(config.connect_timeout_secs);
config.connect_timeout_secs = Number.isFinite(connectTimeout) && connectTimeout > 0 ? connectTimeout : 10;
const queryTimeout = Number(config.query_timeout_secs);
@@ -3774,6 +3796,7 @@ function resetForm() {
selectedJdbcDriverPath.value = "";
connectionUrlInput.value = "";
appliedConnectionUrlInput.value = "";
oracleTnsAdminPath.value = "";
dialogStep.value = "select";
dbSearchQuery.value = "";
selectedDbCategory.value = "sql";
@@ -3822,6 +3845,7 @@ function applyConnectionDraftToConfig(config: Omit<ConnectionConfig, "id">, draf
function applyConnectionDraftToForm(draft: ConnectionDeepLinkDraft) {
applyProfile(draft.driverProfile);
form.value = applyConnectionDraftToConfig(form.value, draft);
oracleTnsAdminPath.value = parseOracleTnsConnectionString(form.value.connection_string)?.tnsAdmin || "";
selectedType.value = draft.driverProfile;
if (form.value.db_type === "h2") {
h2ConnectionMode.value = h2ConnectionModeForConfig(form.value);
@@ -4240,6 +4264,20 @@ async function browseHiveKerberosFile(target: "krb5" | "jaas") {
}
}
async function browseOracleTnsNamesFile() {
if (!isTauriRuntime()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
title: t("connection.oracleTnsAdminBrowse"),
multiple: false,
filters: [{ name: "Oracle TNS names", extensions: ["ora"] }],
});
if (typeof selected === "string") {
oracleTnsAdminPath.value = normalizeOracleTnsAdminPath(selected);
resetTestState();
}
}
async function browseKafkaKerberosFile(target: "keytab" | "krb5") {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
@@ -5664,7 +5702,7 @@ function openExternalUrl(url: string) {
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'oracle' || form.oracle_connection_type !== 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ form.db_type === "elasticsearch" && elasticsearchConnectionMode === "kibana" ? t("connection.elasticsearchKibanaHost") : t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
@@ -5700,6 +5738,26 @@ function openExternalUrl(url: string) {
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">TNS_ADMIN</Label>
<div class="col-span-3 flex items-center gap-1">
<Input v-model="oracleTnsAdminPath" class="flex-1" :placeholder="t('connection.oracleTnsAdminPlaceholder')" />
<Tooltip v-if="isDesktop">
<TooltipTrigger as-child>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="browseOracleTnsNamesFile">
<FolderOpen class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("connection.oracleTnsAdminBrowse") }}</TooltipContent>
</Tooltip>
</div>
</div>
<div v-if="form.db_type === 'oracle' && form.oracle_connection_type === 'tns'" class="grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 text-xs text-muted-foreground">{{ t("connection.oracleTnsPathHint") }}</p>
</div>
<template v-if="form.db_type === 'hive'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.hiveAuthMode") }}</Label>
@@ -5772,12 +5830,12 @@ function openExternalUrl(url: string) {
<div v-if="form.db_type === 'oracle'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<div class="col-span-3 grid h-8 grid-cols-3 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type !== 'sid' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type !== 'sid'"
:class="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'service_name' || !form.oracle_connection_type"
@click="form.oracle_connection_type = 'service_name'"
>
{{ t("connection.serviceNameOnly") }}
@@ -5791,6 +5849,15 @@ function openExternalUrl(url: string) {
>
SID
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="form.oracle_connection_type === 'tns' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="form.oracle_connection_type === 'tns'"
@click="form.oracle_connection_type = 'tns'"
>
TNS
</button>
</div>
</div>
@@ -2090,7 +2090,8 @@ function canDropIndex(index: EditableStructureIndex): boolean {
}
const canEditForeignKeys = computed(() => structureCapabilities.value.foreignKey);
const canEditMysqlTriggers = computed(() => structureDialect.value === "mysql");
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle");
const isOracleTriggerEditor = computed(() => structureDialect.value === "oracle");
function generatedForeignKeyName(column = ""): string {
const table = structureIndexTableName() || "table";
@@ -2138,14 +2139,14 @@ function canEditForeignKeyDraft(foreignKey: EditableStructureForeignKey): boolea
}
function addTrigger() {
if (!canEditMysqlTriggers.value || triggersLoading.value) return;
if (!canEditTriggers.value || triggersLoading.value) return;
activeTab.value = "triggers";
triggers.value.push({
id: `new:${uuid()}`,
name: "",
timing: "BEFORE",
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : "BEFORE",
event: "INSERT",
statement: "BEGIN\n \nEND",
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : "BEGIN\n \nEND",
markedForDrop: false,
});
}
@@ -2160,7 +2161,7 @@ function toggleDropTrigger(trigger: EditableStructureTrigger) {
}
function canEditTriggerDraft(trigger: EditableStructureTrigger): boolean {
return !triggersLoading.value && canEditMysqlTriggers.value && !trigger.markedForDrop;
return !triggersLoading.value && canEditTriggers.value && !trigger.markedForDrop;
}
function primarySqlOperation(sql: string): string {
@@ -2293,7 +2294,7 @@ function addItemForActiveTab(): boolean {
addForeignKey();
return true;
}
if (activeTab.value === "triggers" && canEditMysqlTriggers.value) {
if (activeTab.value === "triggers" && canEditTriggers.value) {
addTrigger();
return true;
}
@@ -2648,7 +2649,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<Plus :class="structureIconClass" />
{{ t("structureEditor.addForeignKey") }}
</Button>
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditMysqlTriggers || triggersLoading" @click="addTrigger">
<Button v-if="activeTab === 'triggers'" size="sm" :class="structureToolbarButtonClass" :disabled="!canEditTriggers || triggersLoading" @click="addTrigger">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addTrigger") }}
</Button>
@@ -3193,9 +3194,10 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
</div>
<div v-else class="space-y-1.5">
<div v-for="trigger in triggers" :key="trigger.id" class="rounded-md border px-[var(--structure-cell-px)] py-[var(--structure-header-py)] text-[length:var(--structure-font-size)]" :class="trigger.markedForDrop ? 'bg-destructive/5 opacity-60' : ''">
<div class="grid grid-cols-[minmax(140px,1fr)_110px_110px_auto] gap-1.5">
<div class="grid grid-cols-[minmax(140px,1fr)_minmax(130px,180px)_minmax(140px,1fr)_auto] gap-1.5">
<Input v-model="trigger.name" :class="structureControlClass" :placeholder="t('structureEditor.triggerName')" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
<Input v-if="isOracleTriggerEditor" v-model="trigger.timing" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-else v-model="trigger.timing" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
<SelectValue />
</SelectTrigger>
@@ -3203,7 +3205,8 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
</SelectContent>
</Select>
<Select v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
<Input v-if="isOracleTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
<Select v-else v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
<SelectValue />
</SelectTrigger>
+8
View File
@@ -210,6 +210,14 @@ export default {
mongoDriverLegacy: "Legacy",
serviceName: "Service/SID",
serviceNameOnly: "Service Name",
oracleTnsAlias: "Network Alias",
oracleTnsAliasPlaceholder: "Alias from tnsnames.ora",
oracleTnsAdminPlaceholder: "Directory containing tnsnames.ora",
oracleTnsAdminBrowse: "Select tnsnames.ora",
oracleTnsPathHint: "The DBX backend/Oracle agent reads this path. Desktop paths are local; Web and Docker paths are on the server.",
oracleTnsAliasRequired: "Oracle TNS mode requires a network alias.",
oracleTnsAdminRequired: "Oracle TNS mode requires the TNS_ADMIN directory containing tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS connections cannot use SSH, proxy, or HTTP tunnel layers. Remove the transport layer or use Service Name/SID mode.",
elasticsearchDirectMode: "Direct",
elasticsearchKibanaProxyMode: "Kibana Proxy",
elasticsearchKibanaHost: "Kibana Host",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy",
serviceName: "Servicio/SID",
serviceNameOnly: "Nombre de servicio",
oracleTnsAlias: "Alias de red",
oracleTnsAliasPlaceholder: "Alias de tnsnames.ora",
oracleTnsAdminPlaceholder: "Directorio que contiene tnsnames.ora",
oracleTnsAdminBrowse: "Seleccionar tnsnames.ora",
oracleTnsPathHint: "La ruta la lee el backend de DBX/Oracle Agent. En escritorio es local; en Web y Docker pertenece al servidor.",
oracleTnsAliasRequired: "El modo Oracle TNS requiere un alias de red.",
oracleTnsAdminRequired: "El modo Oracle TNS requiere el directorio TNS_ADMIN que contiene tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS no se puede combinar con túneles SSH, proxy o HTTP. Elimine la capa de transporte o use Servicio/SID.",
version: "Versión",
driverInstallHintPrefix: "Instala el driver requerido desde ",
driverInstallHintSuffix: " en la barra superior antes de conectar.",
+8
View File
@@ -211,6 +211,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy",
serviceName: "Servizio/SID",
serviceNameOnly: "Nome Servizio",
oracleTnsAlias: "Alias di rete",
oracleTnsAliasPlaceholder: "Alias da tnsnames.ora",
oracleTnsAdminPlaceholder: "Directory contenente tnsnames.ora",
oracleTnsAdminBrowse: "Seleziona tnsnames.ora",
oracleTnsPathHint: "Il percorso viene letto dal backend DBX/Oracle Agent. Sul desktop è locale; su Web e Docker appartiene al server.",
oracleTnsAliasRequired: "La modalità Oracle TNS richiede un alias di rete.",
oracleTnsAdminRequired: "La modalità Oracle TNS richiede la directory TNS_ADMIN contenente tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS non può usare tunnel SSH, proxy o HTTP. Rimuovi il livello di trasporto o usa Servizio/SID.",
version: "Versione",
driverInstallHintPrefix: "Installa il driver richiesto da ",
driverInstallHintSuffix: " nella barra degli strumenti superiore prima di connetterti.",
+8
View File
@@ -211,6 +211,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "レガシー",
serviceName: "サービス/SID",
serviceNameOnly: "サービス名",
oracleTnsAlias: "ネットワーク・エイリアス",
oracleTnsAliasPlaceholder: "tnsnames.ora のエイリアス",
oracleTnsAdminPlaceholder: "tnsnames.ora を含むディレクトリ",
oracleTnsAdminBrowse: "tnsnames.ora を選択",
oracleTnsPathHint: "このパスは DBX バックエンド/Oracle Agent が読み取ります。デスクトップではローカルパス、Web/Docker ではサーバーパスを指定してください。",
oracleTnsAliasRequired: "Oracle TNS モードではネットワーク・エイリアスが必要です。",
oracleTnsAdminRequired: "Oracle TNS モードでは tnsnames.ora を含む TNS_ADMIN ディレクトリが必要です。",
oracleTnsTransportUnsupported: "Oracle TNS 接続は SSH、プロキシ、HTTP トンネルと併用できません。トランスポート層を削除するか、サービス名/SID モードを使用してください。",
version: "バージョン",
driverInstallHintPrefix: "接続前に上部ツールバーの",
driverInstallHintSuffix: "から必要なドライバーをインストールしてください。",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "Legacy",
serviceName: "Service/SID",
serviceNameOnly: "Service Name",
oracleTnsAlias: "Alias de rede",
oracleTnsAliasPlaceholder: "Alias do tnsnames.ora",
oracleTnsAdminPlaceholder: "Diretório que contém tnsnames.ora",
oracleTnsAdminBrowse: "Selecionar tnsnames.ora",
oracleTnsPathHint: "O caminho é lido pelo backend do DBX/Oracle Agent. No desktop é local; no Web e Docker pertence ao servidor.",
oracleTnsAliasRequired: "O modo Oracle TNS requer um alias de rede.",
oracleTnsAdminRequired: "O modo Oracle TNS requer o diretório TNS_ADMIN que contém tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS não pode usar túneis SSH, proxy ou HTTP. Remova a camada de transporte ou use Serviço/SID.",
version: "Versão",
driverInstallHintPrefix: "Instale o driver necessário em ",
driverInstallHintSuffix: " na barra de ferramentas superior antes de conectar.",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "旧版兼容",
serviceName: "服务名/SID",
serviceNameOnly: "服务名",
oracleTnsAlias: "网络别名",
oracleTnsAliasPlaceholder: "tnsnames.ora 中的别名",
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目录",
oracleTnsAdminBrowse: "选择 tnsnames.ora",
oracleTnsPathHint: "该路径由 DBX 后端/Oracle Agent 读取。桌面端填写本机路径,Web 和 Docker 部署填写服务器路径。",
oracleTnsAliasRequired: "Oracle TNS 模式需要填写网络别名。",
oracleTnsAdminRequired: "Oracle TNS 模式需要填写包含 tnsnames.ora 的 TNS_ADMIN 目录。",
oracleTnsTransportUnsupported: "Oracle TNS 连接不能与 SSH、代理或 HTTP 隧道组合使用。请移除传输层,或改用服务名/SID 模式。",
elasticsearchDirectMode: "直连",
elasticsearchKibanaProxyMode: "Kibana 代理",
elasticsearchKibanaHost: "Kibana 主机",
+8
View File
@@ -212,6 +212,14 @@ export default withEnglishFallback({
mongoDriverLegacy: "舊版相容",
serviceName: "服務/SID",
serviceNameOnly: "服務名稱",
oracleTnsAlias: "網路別名",
oracleTnsAliasPlaceholder: "tnsnames.ora 中的別名",
oracleTnsAdminPlaceholder: "包含 tnsnames.ora 的目錄",
oracleTnsAdminBrowse: "選擇 tnsnames.ora",
oracleTnsPathHint: "此路徑由 DBX 後端/Oracle Agent 讀取。桌面版填寫本機路徑,Web 和 Docker 部署填寫伺服器路徑。",
oracleTnsAliasRequired: "Oracle TNS 模式需要填寫網路別名。",
oracleTnsAdminRequired: "Oracle TNS 模式需要填寫包含 tnsnames.ora 的 TNS_ADMIN 目錄。",
oracleTnsTransportUnsupported: "Oracle TNS 連線不能與 SSH、代理或 HTTP Tunnel 組合使用。請移除傳輸層,或改用服務名稱/SID 模式。",
version: "版本",
driverInstallHintPrefix: "需要在上方導覽列「",
driverInstallHintSuffix: "」中安裝對應的驅動程式才能連線。",
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "./oracleTnsConnection";
describe("Oracle TNS connection settings", () => {
it("round-trips an alias and TNS_ADMIN directory", () => {
const value = buildOracleTnsConnectionString("DBX_FAILOVER", "C:\\oracle network\\admin");
expect(parseOracleTnsConnectionString(value)).toEqual({
alias: "DBX_FAILOVER",
tnsAdmin: "C:\\oracle network\\admin",
});
});
it("normalizes a selected tnsnames.ora file to its parent directory", () => {
expect(normalizeOracleTnsAdminPath("C:\\oracle\\network\\admin\\tnsnames.ora")).toBe("C:\\oracle\\network\\admin");
expect(normalizeOracleTnsAdminPath("C:\\tnsnames.ora")).toBe("C:\\");
expect(normalizeOracleTnsAdminPath("/opt/oracle/network/admin/tnsnames.ora")).toBe("/opt/oracle/network/admin");
});
it("does not reinterpret service, SID, or descriptor JDBC URLs as TNS aliases", () => {
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1")).toBeNull();
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@db.example.com:1521:ORCL")).toBeNull();
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(HOST=db.example.com)))")).toBeNull();
});
it("ignores malformed encoded aliases instead of breaking connection editing", () => {
expect(parseOracleTnsConnectionString("jdbc:oracle:thin:@DBX%ZZ?TNS_ADMIN=%2Fopt%2Foracle")).toBeNull();
});
});
@@ -0,0 +1,43 @@
export interface OracleTnsConnectionConfig {
alias: string;
tnsAdmin: string;
}
const ORACLE_TNS_JDBC_PREFIX = "jdbc:oracle:thin:@";
export function parseOracleTnsConnectionString(value?: string): OracleTnsConnectionConfig | null {
const source = value?.trim() || "";
if (!source.toLowerCase().startsWith(ORACLE_TNS_JDBC_PREFIX)) return null;
const target = source.slice(ORACLE_TNS_JDBC_PREFIX.length);
if (!target || target.startsWith("(") || target.startsWith("//") || target.includes(":")) return null;
const [rawAlias, rawQuery = ""] = target.split("?", 2);
let alias = "";
try {
alias = decodeURIComponent(rawAlias).trim();
} catch {
return null;
}
const tnsAdmin = new URLSearchParams(rawQuery).get("TNS_ADMIN")?.trim() || "";
if (!alias) return null;
return { alias, tnsAdmin };
}
export function buildOracleTnsConnectionString(alias: string, tnsAdmin: string): string {
const normalizedAlias = alias.trim();
const normalizedTnsAdmin = normalizeOracleTnsAdminPath(tnsAdmin);
const query = new URLSearchParams({ TNS_ADMIN: normalizedTnsAdmin });
return `${ORACLE_TNS_JDBC_PREFIX}${encodeURIComponent(normalizedAlias)}?${query.toString()}`;
}
export function normalizeOracleTnsAdminPath(value: string): string {
const path = value.trim();
if (!path) return "";
if (!/(^|[\\/])tnsnames\.ora$/i.test(path)) return path;
const parent = path.replace(/[\\/]tnsnames\.ora$/i, "");
if (/^[A-Za-z]:$/.test(parent)) return `${parent}\\`;
if (parent) return parent;
return path.startsWith("\\") ? "\\" : "/";
}
@@ -2,6 +2,7 @@ import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureS
import { extractIdentifierPartsAt, isSqlKeyword, sqlObjectNavigationTarget, type SqlObjectNavigationTarget, type SqlObjectNavigationType } from "@/lib/sql/sqlNavigation";
import type { ActiveTabSidebarTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
import type { SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import type { DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface QueryCursorTableCandidate {
@@ -53,7 +54,9 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data
}
export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null {
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => part.value);
const dialect = sqlSemanticDialectFor({ databaseType: input.databaseType });
// View-data SQL quotes resolved names, so fold only unquoted input exactly as the database does before quoting it again.
const parts = extractQualifiedIdentifierPartsAt(input.sql, input.position).map((part) => dialect.normalizeIdentifier(part.value, part.quoted));
if (parts.length === 0) return null;
const tableName = parts[parts.length - 1];
+1 -1
View File
@@ -140,7 +140,7 @@ export interface ConnectionConfig {
client_cert_path?: string;
client_key_path?: string;
sysdba?: boolean;
oracle_connection_type?: "service_name" | "sid";
oracle_connection_type?: "service_name" | "sid" | "tns";
connection_string?: string;
jdbc_driver_class?: string;
jdbc_driver_paths?: string[];