fix(gbase8s): stabilize connections and table access

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-08-04 09:55:36 +08:00
1 parent 7d5a9459e4
commit 7e6923d53b
26 files changed
+375 -46

No files matched your search

@@ -51,6 +51,7 @@ import { JDBCX_DEFAULT_URL, JDBCX_DRIVER_PROFILE, JDBCX_JDBC_DRIVER_CLASS, ensur
import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDetection";
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints";
import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection";
import { postgresTlsModeForForm } from "@/lib/connection/postgresTlsMode";
import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, resolveMqKafkaConnectionSource, type MqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection";
import { assertCompleteDatabaseCategories, databaseSelectionForCategory } from "@/lib/connection/databaseCategoryOptions";
@@ -4735,7 +4736,7 @@ function openExternalUrl(url: string) {
<template>
<Dialog v-model:open="open">
<DialogContent class="connection-dialog-content" :class="connectionDialogContentClass" :data-wide="shouldUseWideConnectionDialog ? 'true' : undefined" @interact-outside.prevent @escape-key-down="handleDialogEscape">
<DialogContent class="connection-dialog-content" :class="connectionDialogContentClass" :data-wide="shouldUseWideConnectionDialog ? 'true' : undefined" @interact-outside.prevent @escape-key-down="handleDialogEscape" @keydown="preventDialogDocumentSelectAll">
<DialogHeader>
<DialogTitle>{{ editingId ? t("connection.editTitle") : t("connection.title") }}</DialogTitle>
</DialogHeader>
@@ -6000,7 +6001,10 @@ function openExternalUrl(url: string) {
<div v-if="form.driver_profile === 'gbase8s'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.gbaseServer") }}</Label>
<Input v-model="form.gbase_server" class="col-span-3" placeholder="gbase01" />
<div class="col-span-3 space-y-1">
<Input v-model="form.gbase_server" placeholder="gbase01" />
<p class="text-xs text-muted-foreground">{{ t("connection.gbaseServerHint") }}</p>
</div>
</div>
<div v-if="form.db_type === 'informix'" class="grid grid-cols-4 items-center gap-4">
@@ -7126,7 +7130,7 @@ function openExternalUrl(url: string) {
</Dialog>
<Dialog v-model:open="showVisibleDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogContent class="sm:max-w-[520px]" @keydown="preventDialogDocumentSelectAll">
<DialogHeader>
<DialogTitle>{{ t(visibleObjectTitleKey) }}</DialogTitle>
<p class="text-sm text-muted-foreground">
@@ -7174,9 +7178,7 @@ function openExternalUrl(url: string) {
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="visibleDatabaseError" class="p-3 text-sm text-destructive">
{{ t(visibleObjectLoadFailedKey, { message: visibleDatabaseError }) }}
</div>
<textarea v-else-if="visibleDatabaseError" class="h-full w-full resize-none overflow-auto border-0 bg-transparent p-3 text-sm leading-5 text-destructive outline-none" :value="t(visibleObjectLoadFailedKey, { message: visibleDatabaseError })" readonly />
<div v-else-if="!filteredVisibleDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ t("grid.noSearchResults") }}
</div>
@@ -24,6 +24,10 @@ const errorMessage = computed(() => (props.connectionId ? connectionStore.connec
function clearError() {
if (props.connectionId) connectionStore.clearConnectionError(props.connectionId);
}
function selectErrorText(event: FocusEvent) {
if (event.target instanceof HTMLTextAreaElement) event.target.select();
}
</script>
<template>
@@ -40,15 +44,13 @@ function clearError() {
<AlertTriangle class="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" class="w-72 gap-2 p-2 text-xs" @click.stop>
<PopoverContent side="top" class="w-96 max-w-[calc(100vw-2rem)] gap-2 p-2 text-xs" @click.stop>
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<div class="font-medium text-foreground">
{{ t("connection.lastError") }}
</div>
<div class="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-words text-muted-foreground">
{{ errorMessage }}
</div>
<textarea class="mt-1 max-h-40 min-h-20 w-full resize-none overflow-auto rounded border bg-muted/30 p-2 font-mono text-[11px] leading-4 text-muted-foreground outline-none" :value="errorMessage" readonly @focus="selectErrorText" />
</div>
<button type="button" class="shrink-0 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" @click="clearError">{{ t("connection.clearError") }}</button>
</div>
+5
View File
@@ -98,6 +98,11 @@ const CASES: { name: string; message: string; key: string; params?: Record<strin
key: "driverStore.driverUpdateBlocked",
params: { labels: "Prod MySQL, Stage PG" },
},
{
name: "GBase 8s server name mismatch",
message: "Agent RPC error (-1): java.sql.SQLException: GBASEDBTSERVER 与 DBSERVERNAME 或 DBSERVERALIASES 不匹配。",
key: "connection.gbaseServerMismatch",
},
{
name: "Kafka topic unload unsupported",
message: "Kafka does not support unloading topics",
+1
View File
@@ -41,6 +41,7 @@ const patterns: [RegExp, string][] = [
[/^Custom Java runtime path is empty\. Please choose a Java executable\.$/, "connection.customJavaPathEmpty"],
[/^Agent requires Java 21, but DBX started it with an older Java runtime\. Use DBX managed JRE 21 or select a Java 21 executable in Driver Manager\./, "connection.agentJavaTooOld"],
[/^JDBC plugin is not installed\. Install the optional JDBC plugin to use this connection\.$/, "connection.jdbcPluginNotInstalled"],
[/GBASEDBTSERVER[\s\S]*DBSERVERNAME[\s\S]*DBSERVERALIASES/, "connection.gbaseServerMismatch"],
[/^ai\.configNameExists:(.+)$/, "ai.configNameExists"],
// Tunnel / proxy test messages
+2
View File
@@ -263,6 +263,8 @@ export default {
hiveJvmOptions: "JVM Options",
hiveJvmOptionsPlaceholder: "-Dsun.security.krb5.debug=true",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "Must match the server DBSERVERNAME or DBSERVERALIASES.",
gbaseServerMismatch: "GBase 8s connection failed because GBASEDBTSERVER does not match the server DBSERVERNAME or DBSERVERALIASES. Fill GBASEDBTSERVER with the actual server name.",
informixServer: "INFORMIXSERVER",
sslEnable: "Enable encrypted connection",
caCertPath: "CA Certificate",
+2
View File
@@ -248,6 +248,8 @@ export default withEnglishFallback({
d1TokenHint: "El token necesita al menos el permiso D1 Read; las escrituras, el DDL y las importaciones también requieren D1 Write.",
d1FieldsRequired: "Se requieren el ID de cuenta de Cloudflare, el ID de la base de datos D1 y el token de API.",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "Debe coincidir con el DBSERVERNAME del servidor o con DBSERVERALIASES.",
gbaseServerMismatch: "Falló la conexión de GBase 8s: GBASEDBTSERVER no coincide con el DBSERVERNAME del servidor ni con sus DBSERVERALIASES. Introduce el nombre real del servidor.",
informixServer: "INFORMIXSERVER",
sslEnable: "Habilitar conexión cifrada",
caCertPath: "Certificado CA",
+2
View File
@@ -247,6 +247,8 @@ export default withEnglishFallback({
d1TokenHint: "Il token richiede almeno l'autorizzazione D1 Read; per scritture, DDL e importazioni è necessaria anche D1 Write.",
d1FieldsRequired: "Sono obbligatori l'ID account Cloudflare, l'ID database D1 e il token API.",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "Deve corrispondere al DBSERVERNAME del server o a DBSERVERALIASES.",
gbaseServerMismatch: "Connessione GBase 8s non riuscita: GBASEDBTSERVER non corrisponde al DBSERVERNAME del server o a uno dei DBSERVERALIASES. Inserisci il nome server effettivo.",
informixServer: "INFORMIXSERVER",
sslEnable: "Abilita connessione crittografata",
caCertPath: "Certificato CA",
+2
View File
@@ -247,6 +247,8 @@ export default withEnglishFallback({
d1TokenHint: "トークンには少なくともD1 Read権限が必要です。書き込み、DDL、インポートにはD1 Write権限も必要です。",
d1FieldsRequired: "CloudflareアカウントID、D1データベースID、APIトークンは必須です。",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "サーバー側の DBSERVERNAME または DBSERVERALIASES と一致させる必要があります。",
gbaseServerMismatch: "GBase 8s の接続に失敗しました。GBASEDBTSERVER がサーバー側の DBSERVERNAME または DBSERVERALIASES と一致していません。接続設定に実際のサーバー名を入力してください。",
informixServer: "INFORMIXSERVER",
sslEnable: "暗号化接続を有効にする",
caCertPath: "CA証明書",
+2
View File
@@ -259,6 +259,8 @@ export default withEnglishFallback({
hiveJvmOptions: "JVM 옵션",
hiveJvmOptionsPlaceholder: "-Dsun.security.krb5.debug=true",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "서버의 DBSERVERNAME 또는 DBSERVERALIASES와 일치해야 합니다.",
gbaseServerMismatch: "GBase 8s 연결 실패: GBASEDBTSERVER가 서버의 DBSERVERNAME 또는 DBSERVERALIASES와 일치하지 않습니다. 연결 설정에 실제 서버 이름을 입력하세요.",
informixServer: "INFORMIXSERVER",
sslEnable: "암호화된 연결 사용",
caCertPath: "CA 인증서",
+2
View File
@@ -248,6 +248,8 @@ export default withEnglishFallback({
d1TokenHint: "O token precisa de pelo menos a permissão D1 Read; gravações, DDL e importações também exigem D1 Write.",
d1FieldsRequired: "O ID da conta Cloudflare, o ID do banco de dados D1 e o token da API são obrigatórios.",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "Deve corresponder ao DBSERVERNAME do servidor ou a DBSERVERALIASES.",
gbaseServerMismatch: "Falha na conexão de GBase 8s: GBASEDBTSERVER não corresponde ao DBSERVERNAME do servidor nem a um dos DBSERVERALIASES. Informe o nome real do servidor.",
informixServer: "INFORMIXSERVER",
sslEnable: "Habilitar conexão criptografada",
caCertPath: "Certificado CA",
+2
View File
@@ -265,6 +265,8 @@ export default withEnglishFallback({
hiveJvmOptions: "JVM 参数",
hiveJvmOptionsPlaceholder: "-Dsun.security.krb5.debug=true",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "必须与服务端 DBSERVERNAME 或 DBSERVERALIASES 一致。",
gbaseServerMismatch: "GBase 8s 连接失败:GBASEDBTSERVER 与服务端 DBSERVERNAME 或 DBSERVERALIASES 不匹配。请在连接配置中填写实际服务名。",
informixServer: "INFORMIXSERVER",
sslEnable: "启用加密连接",
caCertPath: "CA 证书",
+2
View File
@@ -248,6 +248,8 @@ export default withEnglishFallback({
d1TokenHint: "Token 至少需要 D1 Read 權限;執行寫入、DDL 或匯入時還需要 D1 Write 權限。",
d1FieldsRequired: "必須填寫 Cloudflare Account ID、D1 Database ID 和 API Token。",
gbaseServer: "GBASEDBTSERVER",
gbaseServerHint: "必須與服務端 DBSERVERNAME 或 DBSERVERALIASES 一致。",
gbaseServerMismatch: "GBase 8s 連線失敗:GBASEDBTSERVER 與服務端 DBSERVERNAME 或 DBSERVERALIASES 不匹配。請在連線設定中填寫實際服務名。",
informixServer: "INFORMIXSERVER",
sslEnable: "啟用加密連線",
caCertPath: "CA 憑證",
@@ -5,6 +5,7 @@ import {
connectionObjectTreeNodeSchema,
connectionObjectTreeQuerySchema,
connectionQueryExecutionSchema,
connectionShouldDiscoverJdbcSchemas,
connectionShouldLoadIdentifierQuote,
connectionUsesDatabaseObjectTreeMode,
effectiveDatabaseTypeForConnection,
@@ -87,6 +88,8 @@ describe("jdbc dialect inference", () => {
expect(connectionShouldLoadIdentifierQuote({ db_type: "jdbc", jdbc_driver_class: "org.postgresql.Driver" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "kingbase" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "gaussdb" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "gbase", driver_profile: "gbase8s" })).toBe(true);
expect(connectionShouldLoadIdentifierQuote({ db_type: "gbase", driver_profile: "gbase8a" })).toBe(false);
expect(
connectionShouldLoadIdentifierQuote({
db_type: "jdbc",
@@ -96,6 +99,11 @@ describe("jdbc dialect inference", () => {
).toBe(false);
});
it("falls back to a flat table tree when GBase 8s reports no schemas", () => {
expect(connectionShouldDiscoverJdbcSchemas({ db_type: "gbase", driver_profile: "gbase8s" })).toBe(true);
expect(connectionShouldDiscoverJdbcSchemas({ db_type: "gbase", driver_profile: "gbase8a" })).toBe(false);
});
it("recognizes GaussDB reached through PostgreSQL-compatible JDBC drivers", () => {
const connection = {
db_type: "jdbc" as const,
@@ -34,6 +34,17 @@ describe("qualifiedTableName — SQLite attached databases", () => {
});
});
describe("qualifiedTableName — GBase 8s", () => {
it("keeps the owner unquoted when the driver reports no identifier quote support", () => {
expect(qualifiedTableName({ databaseType: "informix", identifierQuote: "", schema: "gbasedbt", tableName: "connection_smoke" })).toBe("gbasedbt.connection_smoke");
expect(quoteTableDataIdentifier("informix", "connection_smoke", "")).toBe("connection_smoke");
});
it("keeps native Informix qualification when no driver capability was loaded", () => {
expect(qualifiedTableName({ databaseType: "informix", schema: "gbasedbt", tableName: "connection_smoke" })).toBe("gbasedbt.connection_smoke");
});
});
describe("quoteTableIdentifier", () => {
it("backtick-quotes mysql identifiers", () => {
expect(quoteTableIdentifier("mysql", "orders")).toBe("`orders`");
@@ -0,0 +1,49 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import { preventDialogDocumentSelectAll } from "../dialogTextSelection";
function keyboardEvent(target: EventTarget, options: { key?: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean } = {}) {
return {
key: options.key ?? "a",
metaKey: options.metaKey ?? false,
ctrlKey: options.ctrlKey ?? false,
altKey: options.altKey ?? false,
target,
preventDefault: vi.fn(),
} as unknown as KeyboardEvent;
}
describe("preventDialogDocumentSelectAll", () => {
it("prevents page selection from non-text dialog surfaces", () => {
const event = keyboardEvent(document.createElement("div"), { metaKey: true });
expect(preventDialogDocumentSelectAll(event)).toBe(true);
expect(event.preventDefault).toHaveBeenCalledOnce();
});
it.each([document.createElement("input"), document.createElement("textarea")])("keeps native select-all for text controls", (target) => {
const event = keyboardEvent(target, { ctrlKey: true });
expect(preventDialogDocumentSelectAll(event)).toBe(false);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("keeps native select-all for nested textbox content", () => {
const textbox = document.createElement("div");
textbox.setAttribute("role", "textbox");
const child = document.createElement("span");
textbox.appendChild(child);
const event = keyboardEvent(child, { metaKey: true });
expect(preventDialogDocumentSelectAll(event)).toBe(false);
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("does not intercept ordinary typing", () => {
const event = keyboardEvent(document.createElement("div"));
expect(preventDialogDocumentSelectAll(event)).toBe(false);
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,11 @@
function isDialogTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable) || !!target.closest("[contenteditable='true'], [role='textbox']");
}
export function preventDialogDocumentSelectAll(event: KeyboardEvent): boolean {
const isSelectAll = event.key.toLowerCase() === "a" && (event.metaKey || event.ctrlKey) && !event.altKey;
if (!isSelectAll || isDialogTextInputTarget(event.target)) return false;
event.preventDefault();
return true;
}
@@ -69,6 +69,7 @@ export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConne
export function connectionShouldLoadIdentifierQuote(connection: JdbcDialectConnection | undefined): boolean {
if (!connection) return false;
if (connection.db_type === "gbase" && isGbase8sProfile(connection.driver_profile)) return true;
if (connection.db_type === "kingbase") return true;
if (gaussdbIdentifierQuoteStyle(connection) !== "auto") return false;
if (connection.db_type === "gaussdb") return true;
@@ -142,6 +143,9 @@ export function connectionUsesDatabaseObjectTreeMode(connection?: JdbcDialectCon
}
export function connectionShouldDiscoverJdbcSchemas(connection?: JdbcDialectConnection): boolean {
// GBase 8s exposes owner schemas only when the current database can use them
// in DML; non-ANSI databases fall back to the flat table tree.
if (connection?.db_type === "gbase" && isGbase8sProfile(connection.driver_profile)) return true;
return connection?.db_type === "jdbc" && !inferJdbcDialect(connection);
}
+6 -1
View File
@@ -39,7 +39,7 @@ export function quoteTableIdentifier(databaseType: DatabaseType | undefined, nam
export function quoteTableDataIdentifier(databaseType: DatabaseType | undefined, name: string, identifierQuote?: string): string {
if ((databaseType === "gaussdb" || databaseType === "opengauss" || databaseType === "postgres") && identifierQuote != null) return quoteGaussDbJdbcIdentifier(name, identifierQuote);
if (databaseType === "kingbase" && identifierQuote != null) {
if ((databaseType === "kingbase" || databaseType === "informix") && identifierQuote != null) {
if (!identifierQuote) return name;
return `${identifierQuote}${name.replaceAll(identifierQuote, identifierQuote + identifierQuote)}${identifierQuote}`;
}
@@ -81,6 +81,11 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
}
return quotedTable;
}
if (databaseType === "informix" && identifierQuote != null) {
const quotedTable = quoteTableDataIdentifier(databaseType, tableName, identifierQuote);
const trimmedSchema = schema?.trim();
return trimmedSchema ? `${quoteTableDataIdentifier(databaseType, trimmedSchema, identifierQuote)}.${quotedTable}` : quotedTable;
}
if ((isSchemaAware(databaseType) || databaseType === "sqlite") && !usesDatabaseObjectTreeMode(databaseType) && schema) {
if (databaseType === "sqlserver") {
const linked = parseSqlServerLinkedSchema(schema);
@@ -88,6 +88,17 @@ function informixConnection(): ConnectionConfig {
} as ConnectionConfig;
}
function gbase8sConnection(): ConnectionConfig {
return {
...informixConnection(),
id: "gbase8s-1",
name: "GBase 8s",
db_type: "gbase",
driver_profile: "gbase8s",
database: "dbx_test",
} as ConnectionConfig;
}
function procedure(name: string): ObjectInfo {
return {
name,
@@ -270,6 +281,40 @@ describe("connectionStore metadata loading", () => {
expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([["table", "t", undefined]]);
});
it("keeps the flat object tree for GBase 8s databases that cannot qualify schemas in DML", async () => {
const listSchemaInfos = vi.fn().mockResolvedValue([]);
const listTables = vi.fn().mockResolvedValue([{ name: "connection_smoke", table_type: "TABLE", comment: null }]);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
listObjects: vi.fn().mockResolvedValue([]),
listSchemaInfos,
listTables,
loadSchemaCache: vi.fn().mockResolvedValue(null),
saveSchemaCache: vi.fn().mockResolvedValue(undefined),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useConnectionStore();
useSettingsStore().editorSettings.sidebarObjectDisplay = "simple";
const connection = gbase8sConnection();
const databaseNode: TreeNode = { id: `${connection.id}:dbx_test`, label: "dbx_test", type: "database", connectionId: connection.id, database: "dbx_test", isExpanded: false, children: [] };
store.connections = [connection];
store.connectedIds.add(connection.id);
store.treeNodes = [{ id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, isExpanded: true, children: [databaseNode] }];
await store.loadTreeNodeChildren(databaseNode, { force: true });
expect(listSchemaInfos).toHaveBeenCalledWith(connection.id, "dbx_test");
expect(listTables).toHaveBeenCalled();
expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([["table", "connection_smoke", undefined]]);
});
it("renders simple-mode table children without waiting for supplemental objects", async () => {
const tables: TableInfo[] = [{ name: "users", table_type: "BASE TABLE", comment: null }];
const listTables = vi.fn().mockResolvedValue(tables);