fix(jdbc): simplify driver selection and table edits

This commit is contained in:
t8y2 2026-06-11 22:13:27 +08:00
parent 70f7b464b7
commit c7fb855000
6 changed files with 96 additions and 26 deletions

View File

@ -4,12 +4,14 @@ import { uuid } from "@/lib/utils";
import { useI18n } from "vue-i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, DatabaseType, JdbcDriverInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@ -254,6 +256,7 @@ const jdbcDrivers = ref<JdbcDriverInfo[]>([]);
const jdbcMavenBundles = ref<JdbcMavenBundleInfo[]>([]);
const agentDrivers = ref<AgentDriverInstallState[]>([]);
const selectedJdbcDriverPath = ref("");
const jdbcManualClasspathOpen = ref(false);
const connectionUrlInput = ref("");
const oceanbaseSubMode = ref<"mysql" | "oracle">("mysql");
const h2ConnectionMode = ref<H2ConnectionMode>("file");
@ -293,6 +296,13 @@ const jdbcDriverSelectItems = computed<JdbcDriverSelectItem[]>(() => {
});
const jdbcDriverSelectItemById = computed(() => new Map(jdbcDriverSelectItems.value.map((item) => [item.id, item])));
const jdbcManualClasspathCount = computed(
() =>
jdbcDriverPathsInput.value
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean).length,
);
function applyCustomColor(value: string) {
form.value.color = value;
@ -751,7 +761,7 @@ const iconTypeMap: Record<string, string> = {
custom_postgres: "postgres",
};
const dbOptions = [
const dbOptions: DbOption[] = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" },
{ value: "mongodb", label: "MongoDB" },
@ -811,12 +821,19 @@ const dbOptions = [
{ value: "influxdb", label: "InfluxDB" },
{ value: "iris", label: "IRIS" },
{ value: "jdbc", label: "JDBC" },
{ value: "custom_mysql", label: "Custom (MySQL)" },
{ value: "custom_postgres", label: "Custom (PostgreSQL)" },
];
const dbCategories = computed<DbCategory[]>(() => [{ key: "all", title: "", options: dbOptions }]);
function matchesDbOption(option: DbOption, keyword: string, categoryTitle = "") {
const profile = driverProfiles[option.value];
return [option.label, option.value, profile?.label, profile?.type, categoryTitle].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
);
}
const filteredDbCategories = computed<DbCategory[]>(() => {
const keyword = dbSearchQuery.value.trim().toLowerCase();
if (!keyword) return dbCategories.value;
@ -824,14 +841,7 @@ const filteredDbCategories = computed<DbCategory[]>(() => {
return dbCategories.value
.map((category) => ({
...category,
options: category.options.filter((option) => {
const profile = driverProfiles[option.value];
return [option.label, option.value, profile?.label, profile?.type, category.title].some((value) =>
String(value || "")
.toLowerCase()
.includes(keyword),
);
}),
options: category.options.filter((option) => matchesDbOption(option, keyword, category.title)),
}))
.filter((category) => category.options.length > 0);
});
@ -2003,6 +2013,7 @@ function onJdbcDriverSelect(id: any) {
if (!item) return;
selectedJdbcDriverPath.value = id;
addJdbcDriverPaths(item.paths);
jdbcManualClasspathOpen.value = false;
}
function openExternalUrl(url: string) {
@ -2043,7 +2054,7 @@ function openExternalUrl(url: string) {
<div class="max-h-[58vh] space-y-5 overflow-y-auto pr-2">
<section v-for="category in filteredDbCategories" :key="category.key" class="space-y-2">
<div class="flex items-center">
<h3 class="text-sm font-medium">{{ category.title }}</h3>
<h3 v-if="category.title" class="text-sm font-medium">{{ category.title }}</h3>
</div>
<div v-if="dbPickerView === 'icon'" class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-5">
@ -2229,10 +2240,6 @@ function openExternalUrl(url: string) {
<Label class="text-right">{{ t("connection.password") }}</Label>
<Input v-model="form.password" type="password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.jdbcDriverClass") }}</Label>
<Input v-model="form.jdbc_driver_class" class="col-span-3" :placeholder="t('connection.jdbcDriverClassPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label class="text-right mt-2">{{ t("connection.jdbcDriverPaths") }}</Label>
<div class="col-span-3 space-y-2">
@ -2246,7 +2253,16 @@ function openExternalUrl(url: string) {
</SelectItem>
</SelectContent>
</Select>
<div class="flex items-start gap-1">
<div class="flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex min-w-0 items-center gap-2">
<div class="truncate text-xs font-medium">{{ t("connection.jdbcManualClasspath") }}</div>
<Badge variant="outline" class="h-5 shrink-0 rounded-full px-2 text-[10px] font-medium">
{{ t("connection.jdbcManualClasspathCount", { count: jdbcManualClasspathCount }) }}
</Badge>
</div>
<Switch v-model="jdbcManualClasspathOpen" />
</div>
<div v-if="jdbcManualClasspathOpen" class="flex items-start gap-1">
<textarea
v-model="jdbcDriverPathsInput"
class="flex min-h-12 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
@ -2263,6 +2279,10 @@ function openExternalUrl(url: string) {
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label class="text-right">{{ t("connection.jdbcDriverClass") }}</Label>
<Input v-model="form.jdbc_driver_class" class="col-span-3" :placeholder="t('connection.jdbcDriverClassPlaceholder')" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<span />
<div class="col-span-3 space-y-2">

View File

@ -179,7 +179,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const isSaving = ref(false);
const saveError = ref("");
const useTransaction = computed(() => editable.value && supportsDataGridTransaction(databaseType.value) && (!!customSaveHandler?.value || (!!connectionId.value && !!database.value && !!tableMeta.value)));
const hasBackendSaveTarget = computed(() => !!connectionId.value && !!tableMeta.value);
const useTransaction = computed(() => editable.value && supportsDataGridTransaction(databaseType.value) && (!!customSaveHandler?.value || hasBackendSaveTarget.value));
if (hasPendingChanges.value && useTransaction.value) {
transactionActive.value = true;
@ -648,7 +649,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
async function recordDataGridHistory(statements: string[], rollbackStatements: string[], elapsed: number, historyResult?: { affected_rows?: number; success?: boolean; error?: string }) {
if (!connectionId.value || !database.value || !tableMeta.value) return;
if (!connectionId.value || !tableMeta.value) return;
const connName = connectionStore.getConfig(connectionId.value)?.name || "";
const success = historyResult?.success ?? true;
const details = {
@ -664,7 +665,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
await historyStore.add({
connection_id: connectionId.value,
connection_name: connName,
database: database.value,
database: database.value ?? "",
sql: statements.join("\n"),
execution_time_ms: elapsed,
success,
@ -757,17 +758,17 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
rollbackStatements: rollbackStmts,
});
if (useTransaction.value && connectionId.value && database.value) {
if (useTransaction.value && hasBackendSaveTarget.value) {
try {
apiResult = await api.executeInTransaction(connectionId.value, database.value, stmts, preparedSave?.executionSchema);
apiResult = await api.executeInTransaction(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema);
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
return;
}
} else if (connectionId.value && database.value) {
} else if (hasBackendSaveTarget.value) {
try {
apiResult = await api.executeBatch(connectionId.value, database.value, stmts);
apiResult = await api.executeBatch(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema);
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;

View File

@ -317,6 +317,8 @@
jdbcDriverClassPlaceholder: "Most drivers auto-register; use com.vendor.jdbc.Driver if needed",
jdbcDriverPaths: "Driver JARs",
jdbcDriverSelectPlaceholder: "Choose imported driver",
jdbcManualClasspath: "Manual classpath",
jdbcManualClasspathCount: "{count} paths",
jdbcDriverPathsPlaceholder: "/path/to/driver.jar\n/path/to/another-driver.jar",
jdbcDriverBrowse: "Choose JDBC driver JAR",
jdbcDocs: "View JDBC docs",

View File

@ -318,6 +318,8 @@
jdbcDriverClassPlaceholder: "多数驱动可自动识别;需要时填写 com.vendor.jdbc.Driver",
jdbcDriverPaths: "驱动 JAR",
jdbcDriverSelectPlaceholder: "选择已导入驱动",
jdbcManualClasspath: "手动 classpath",
jdbcManualClasspathCount: "{count} 个路径",
jdbcDriverPathsPlaceholder: "/path/to/driver.jar\n/path/to/another-driver.jar",
jdbcDriverBrowse: "选择 JDBC 驱动 JAR",
jdbcDocs: "查看 JDBC 文档",

View File

@ -1,6 +1,6 @@
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import type { QueryResult, QueryTab } from "@/types/database";
import type { ConnectionConfig, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
@ -19,10 +19,22 @@ export function isConnectionReadonly(connectionId: string): boolean {
return connectionStore.getConfig(connectionId)?.read_only ?? false;
}
function jdbcTargetLabel(connection: ConnectionConfig): string {
const url = connection.connection_string?.trim() || "";
const serviceMatch = url.match(/@\/\/[^/?;]+\/([^?;]+)/);
if (serviceMatch?.[1]) return serviceMatch[1];
const sidMatch = url.match(/@[^:]+:\d+:([^?;]+)/);
if (sidMatch?.[1]) return sidMatch[1];
const pathMatch = url.match(/^jdbc:[^:]+:\/\/[^/?;]+\/([^?;]+)/);
if (pathMatch?.[1]) return pathMatch[1];
return connection.driver_label || "JDBC";
}
export function databaseDisplayNameForTab(connectionId: string, database: string, t: Translate): string {
const connectionStore = useConnectionStore();
const connection = connectionStore.getConfig(connectionId);
if (connection?.db_type === "redis" && database !== "") return `db${database}`;
if (connection?.db_type === "jdbc" && !database) return jdbcTargetLabel(connection);
return database || t("editor.noDatabase");
}

View File

@ -1,7 +1,7 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { executionSummaryItems, tabDisplayTitle, tabularResultItems } from "../../apps/desktop/src/lib/tabPresentation.ts";
import { databaseDisplayNameForTab, executionSummaryItems, tabDisplayTitle, tabularResultItems } from "../../apps/desktop/src/lib/tabPresentation.ts";
import { useConnectionStore } from "../../apps/desktop/src/stores/connectionStore.ts";
import type { ConnectionConfig, QueryResult, QueryTab } from "../../apps/desktop/src/types/database.ts";
@ -71,6 +71,39 @@ test("query tab display title uses custom title when present", () => {
}
});
test("jdbc tabs use the connection target when database is empty", () => {
const restoreStorage = installMemoryStorage();
setActivePinia(createPinia());
useConnectionStore().addEphemeralConnection({
...conn("conn-1"),
db_type: "jdbc",
connection_string: "jdbc:oracle:thin:@172.20.66.143:20001:XE",
});
const t = (key: string) => (key === "editor.noDatabase" ? "No database selected" : key);
try {
assert.equal(databaseDisplayNameForTab("conn-1", "", t), "XE");
assert.equal(
tabDisplayTitle(
queryTab({
database: "",
mode: "data",
tableMeta: {
schema: "SYSTEM",
tableName: "DBX_JDBC_TEST",
columns: [],
primaryKeys: ["ID"],
},
}),
t,
),
"DBX_JDBC_TEST@XE.SYSTEM",
);
} finally {
restoreStorage();
}
});
test("tabular result items hide statement results without returned columns", () => {
const results = [result([]), result(["id"]), result([]), result(["name"])];