diff --git a/agents/drivers/xugu/main.go b/agents/drivers/xugu/main.go index 4e765c217f..b1dac4ce8b 100644 --- a/agents/drivers/xugu/main.go +++ b/agents/drivers/xugu/main.go @@ -1384,6 +1384,9 @@ func (s *server) validateConnection() error { } func openDB(params connectParams) (*sql.DB, error) { + if configuredDatabaseName(params) == "" { + return nil, errors.New("XuguDB requires an existing database name; set Database or include DB in the connection string") + } dsn := buildDSN(params) db, err := sql.Open("xugu", dsn) if err != nil { @@ -1920,13 +1923,47 @@ func isXuguMissingOnNullColumnError(err error) bool { } func xuguDSNValue(dsn string, key string) string { - for _, part := range strings.Split(dsn, ";") { + var result string + for _, part := range splitXuguDSNSegments(dsn) { name, value, ok := strings.Cut(part, "=") if ok && strings.EqualFold(strings.TrimSpace(name), key) { - return strings.TrimSpace(value) + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + value = strings.ReplaceAll(value[1:len(value)-1], "''", "'") + } + result = strings.TrimSpace(value) } } - return "" + return result +} + +func splitXuguDSNSegments(dsn string) []string { + var segments []string + var current strings.Builder + inQuotes := false + + for index := 0; index < len(dsn); index++ { + character := dsn[index] + if character == '\'' { + current.WriteByte(character) + if inQuotes && index+1 < len(dsn) && dsn[index+1] == '\'' { + current.WriteByte(dsn[index+1]) + index++ + } else { + inQuotes = !inQuotes + } + continue + } + if character == ';' && !inQuotes { + segments = append(segments, current.String()) + current.Reset() + continue + } + current.WriteByte(character) + } + + segments = append(segments, current.String()) + return segments } func (s *server) listSchemas() ([]string, error) { diff --git a/agents/drivers/xugu/main_test.go b/agents/drivers/xugu/main_test.go index caaa99f16f..9dd2e95a81 100644 --- a/agents/drivers/xugu/main_test.go +++ b/agents/drivers/xugu/main_test.go @@ -452,6 +452,34 @@ func TestBuildDSNUsesConnectionFields(t *testing.T) { } } +func TestOpenDBRejectsMissingDatabaseBeforeOpeningDriver(t *testing.T) { + tests := []struct { + name string + params connectParams + }{ + {name: "empty structured database", params: connectParams{Host: "db.example.com", Username: "app"}}, + {name: "whitespace structured database", params: connectParams{Host: "db.example.com", Database: " \t ", Username: "app"}}, + {name: "empty native DSN database", params: connectParams{ConnectionString: "IP=db.example.com;DB=;User=app"}}, + {name: "quoted whitespace native DSN database", params: connectParams{ConnectionString: "IP=db.example.com;DB=' ';User=app"}}, + {name: "URL without database", params: connectParams{ConnectionString: "xugu://app:secret@db.example.com:5138/"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + db, err := openDB(test.params) + if err == nil { + if db != nil { + _ = db.Close() + } + t.Fatal("expected missing database to be rejected before opening the driver") + } + if !strings.Contains(err.Error(), "XuguDB requires an existing database name") { + t.Fatalf("unexpected missing-database error: %v", err) + } + }) + } +} + func TestBuildDSNUsesDefaultPort(t *testing.T) { dsn := buildDSN(connectParams{ Host: "db.example.com", @@ -707,6 +735,9 @@ func TestConfiguredDatabaseName(t *testing.T) { {params: connectParams{ConnectionString: "xugu://user:secret@db.example.com:5138/demo"}, want: "demo"}, {params: connectParams{ConnectionString: "jdbc:xugu://db.example.com:5138/reporting"}, want: "reporting"}, {params: connectParams{ConnectionString: "IP=db.example.com;DB=SYSTEM;User=SYSDBA;PWD=secret"}, want: "SYSTEM"}, + {params: connectParams{ConnectionString: "IP=db.example.com;DB='shop;east';User=APP"}, want: "shop;east"}, + {params: connectParams{ConnectionString: "IP=db.example.com;DB='sales''east';User=APP"}, want: "sales'east"}, + {params: connectParams{ConnectionString: "IP=db.example.com;DB=' ';User=APP"}, want: ""}, } for _, tc := range cases { diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index 3827166093..a12c6ee5d2 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -69,6 +69,7 @@ import { import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { applyMeilisearchBasePathToExternalConfig, applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnectionUrl } from "@/lib/connection/connectionUrl"; +import { hasXuguConnectionDatabase } from "@/lib/connection/xuguDatabase"; import { DEFAULT_QUERY_TIMEOUT_SECS, MAX_CONNECT_TIMEOUT_SECS, MAX_QUERY_TIMEOUT_SECS } from "@/lib/connection/timeoutLimits"; import { buildOracleTnsConnectionString, normalizeOracleTnsAdminPath, parseOracleTnsConnectionString } from "@/lib/connection/oracleTnsConnection"; import { connectionDeepLinkServiceHydrationValue, parseConnectionDeepLink, parseServiceConnectionUrl, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; @@ -2853,6 +2854,7 @@ const databaseLabel = computed(() => { const databasePlaceholder = computed(() => { if (form.value.db_type === "oracle" && form.value.oracle_connection_type === "tns") return t("connection.oracleTnsAliasPlaceholder"); + if (form.value.db_type === "xugu") return t("connection.databasePlaceholderRequired"); if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired"); const fallback = defaultDatabaseForProfile(); if (!fallback) return t("connection.databasePlaceholder"); @@ -4180,6 +4182,12 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi if (!config.name?.trim()) { config.name = generatedName.trim() || generateConnectionName(); } + if (config.db_type === "xugu") { + config.database = config.database?.trim() || undefined; + if (!hasXuguConnectionDatabase(config.database, config.connection_string)) { + throw new Error(t("connection.xuguDatabaseRequired")); + } + } if (config.db_type === "kingbase") { config.database = config.database?.trim() || undefined; if (!config.database) { diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 26d9311f55..99948b68d7 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -573,6 +573,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "Tələb olunur, mövcud verilənlər bazasını daxil edin", databasePlaceholderWithDefault: "İstəyə bağlı, standart olaraq {database}", kingbaseDatabaseRequired: "KingbaseES əlaqə qurmazdan və ya verilənlər bazası siyahısını yükləməzdən əvvəl mövcud verilənlər bazası tələb edir.", + xuguDatabaseRequired: "XuguDB-yə qoşulmaq üçün əvvəlcə mövcud verilənlər bazası daxil edilməlidir.", defaultDatabase: "Standart verilənlər bazası", authDatabase: "Autentifikasiya verilənlər bazası", authDatabasePlaceholder: "İstəyə bağlı, adətən admin", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 24a0294366..0ba32b0a5c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -716,6 +716,7 @@ export default { databasePlaceholderRequired: "Required, enter an existing database", databasePlaceholderWithDefault: "Optional, defaults to {database}", kingbaseDatabaseRequired: "KingbaseES requires an existing database before connecting or loading the database list.", + xuguDatabaseRequired: "XuguDB requires an existing database before connecting.", defaultDatabase: "Default DB", authDatabase: "Auth DB", authDatabasePlaceholder: "Optional, often admin", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 1b82ef0629..2304a4aba4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -708,6 +708,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "Obligatorio, ingresa una base de datos existente", databasePlaceholderWithDefault: "Opcional, por defecto {database}", kingbaseDatabaseRequired: "KingbaseES requiere una base de datos existente antes de conectar o cargar la lista de bases de datos.", + xuguDatabaseRequired: "XuguDB requiere una base de datos existente antes de conectarse.", defaultDatabase: "BD predeterminada", authDatabase: "BD de autenticación", authDatabasePlaceholder: "Opcional, normalmente admin", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a8979d447c..150e1a72bf 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -706,6 +706,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "Obbligatorio, inserisci un database esistente", databasePlaceholderWithDefault: "Opzionale, predefinito {database}", kingbaseDatabaseRequired: "KingbaseES richiede un database esistente prima di connettersi o caricare l'elenco dei database.", + xuguDatabaseRequired: "XuguDB richiede un database esistente prima di connettersi.", defaultDatabase: "DB Predefinito", authDatabase: "DB di Autenticazione", authDatabasePlaceholder: "Opzionale, spesso admin", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 14145d8f11..10ea900031 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -706,6 +706,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "必須、既存のデータベースを入力", databasePlaceholderWithDefault: "任意、デフォルトは {database}", kingbaseDatabaseRequired: "KingbaseES は接続またはデータベース一覧の読み込み前に、既存のデータベース名が必要です。", + xuguDatabaseRequired: "XuguDB に接続するには、既存のデータベース名が必要です。", defaultDatabase: "デフォルトDB", authDatabase: "認証DB", authDatabasePlaceholder: "任意、通常は admin", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 92bebc4d69..67504fed51 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -708,6 +708,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "필수, 기존 데이터베이스 입력", databasePlaceholderWithDefault: "선택, 기본값 {database}", kingbaseDatabaseRequired: "KingbaseES는 연결이나 데이터베이스 목록을 불러오기 전에 기존 데이터베이스가 필요합니다.", + xuguDatabaseRequired: "XuguDB에 연결하려면 기존 데이터베이스가 필요합니다.", defaultDatabase: "기본 DB", authDatabase: "인증 DB", authDatabasePlaceholder: "선택, 보통 admin", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 5bfbdd792f..e58b50aff6 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -707,6 +707,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "Obrigatório, informe um banco de dados existente", databasePlaceholderWithDefault: "Opcional, padrão é {database}", kingbaseDatabaseRequired: "KingbaseES exige um banco de dados existente antes de conectar ou carregar a lista de bancos de dados.", + xuguDatabaseRequired: "XuguDB exige um banco de dados existente antes de conectar.", defaultDatabase: "BD padrão", authDatabase: "BD de autenticação", authDatabasePlaceholder: "Opcional, geralmente admin", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index fadb8504c6..1ae17b7c50 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -575,6 +575,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "Zorunlu, mevcut bir veritabanı girin", databasePlaceholderWithDefault: "İsteğe bağlı, varsayılan {database}", kingbaseDatabaseRequired: "KingbaseES bağlanmadan veya veritabanı listesini yüklemeden önce mevcut bir veritabanı ister.", + xuguDatabaseRequired: "XuguDB'ye bağlanmadan önce mevcut bir veritabanı gerekir.", defaultDatabase: "Varsayılan VT", authDatabase: "Kimlik Doğrulama VT", authDatabasePlaceholder: "İsteğe bağlı, genellikle admin", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index b2edaab060..094b2af83b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -632,6 +632,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "必填,请填写已存在的数据库", databasePlaceholderWithDefault: "可选,默认 {database}", kingbaseDatabaseRequired: "KingbaseES 需要先填写一个已存在的数据库后才能连接或加载数据库列表。", + xuguDatabaseRequired: "虚谷 XuguDB 需要先填写一个已存在的数据库才能连接。", defaultDatabase: "默认库", authDatabase: "认证库", authDatabasePlaceholder: "可选,通常为 admin", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a9a97bfa3b..2d21976393 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -710,6 +710,7 @@ export default withEnglishFallback({ databasePlaceholderRequired: "必填,請填寫已存在的資料庫", databasePlaceholderWithDefault: "可選,預設 {database}", kingbaseDatabaseRequired: "KingbaseES 需要先填寫一個已存在的資料庫後才能連線或載入資料庫清單。", + xuguDatabaseRequired: "虛谷 XuguDB 需要先填寫一個已存在的資料庫才能連線。", defaultDatabase: "預設 DB", authDatabase: "驗證 DB", authDatabasePlaceholder: "可選,通常為 admin", diff --git a/apps/desktop/src/lib/__tests__/connection/connectionDialogXuguDatabase.spec.ts b/apps/desktop/src/lib/__tests__/connection/connectionDialogXuguDatabase.spec.ts new file mode 100644 index 0000000000..9ba69bb196 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/connectionDialogXuguDatabase.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8"); + +describe("ConnectionDialog Xugu database requirement", () => { + it("shows the existing required-database hint only for Xugu", () => { + const placeholder = dialogSource.slice(dialogSource.indexOf("const databasePlaceholder = computed"), dialogSource.indexOf("const transportLayers")); + expect(placeholder).toContain('if (form.value.db_type === "xugu") return t("connection.databasePlaceholderRequired")'); + expect(placeholder).toContain('if (form.value.db_type === "kingbase") return t("connection.databasePlaceholderRequired")'); + }); + + it("validates Xugu's effective database before connection testing or saving", () => { + const submit = dialogSource.slice(dialogSource.indexOf("function connectionConfigForSubmit"), dialogSource.indexOf("function ", dialogSource.indexOf("function connectionConfigForSubmit") + 10)); + expect(submit).toContain('if (config.db_type === "xugu")'); + expect(submit).toContain("hasXuguConnectionDatabase(config.database, config.connection_string)"); + expect(submit).toContain('throw new Error(t("connection.xuguDatabaseRequired"))'); + expect(submit).toContain('if (config.db_type === "kingbase")'); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/connection/xuguDatabase.spec.ts b/apps/desktop/src/lib/__tests__/connection/xuguDatabase.spec.ts new file mode 100644 index 0000000000..6b69b18bc6 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/connection/xuguDatabase.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { hasXuguConnectionDatabase, xuguDatabaseFromConnectionString } from "@/lib/connection/xuguDatabase"; + +describe("Xugu connection database validation", () => { + it("accepts a database selected in the structured field", () => { + expect(hasXuguConnectionDatabase(" XUGU_TEST_DB ", undefined)).toBe(true); + }); + + it("rejects a missing or whitespace-only database", () => { + expect(hasXuguConnectionDatabase(undefined, undefined)).toBe(false); + expect(hasXuguConnectionDatabase(" \t ", undefined)).toBe(false); + }); + + it.each(["xugu://user:password@host:5138/XUGU_TEST_DB", "jdbc:xugu://host:5138/XUGU_TEST_DB"])("accepts a database encoded in a Xugu URL: %s", (url) => { + expect(xuguDatabaseFromConnectionString(url)).toBe("XUGU_TEST_DB"); + expect(hasXuguConnectionDatabase(undefined, url)).toBe(true); + }); + + it("accepts a database in a native Xugu DSN, including quoted separators", () => { + expect(xuguDatabaseFromConnectionString("IP=db.example;DB='shop;east';User=app")).toBe("shop;east"); + expect(xuguDatabaseFromConnectionString("IP=db.example;DB='sales''east';User=app")).toBe("sales'east"); + }); + + it("rejects a missing or empty database in a native Xugu DSN", () => { + expect(xuguDatabaseFromConnectionString("IP=db.example;User=app")).toBeUndefined(); + expect(xuguDatabaseFromConnectionString("IP=db.example;DB=;User=app")).toBeUndefined(); + expect(xuguDatabaseFromConnectionString("IP=db.example;DB=' ';User=app")).toBeUndefined(); + }); + + it("does not treat another database's URL as a Xugu database", () => { + expect(xuguDatabaseFromConnectionString("postgres://user:password@host:5432/app")).toBeUndefined(); + expect(hasXuguConnectionDatabase(undefined, "postgres://user:password@host:5432/app")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/connection/xuguDatabase.ts b/apps/desktop/src/lib/connection/xuguDatabase.ts new file mode 100644 index 0000000000..648843a84b --- /dev/null +++ b/apps/desktop/src/lib/connection/xuguDatabase.ts @@ -0,0 +1,63 @@ +import { parseConnectionUrl } from "@/lib/connection/connectionUrl"; + +function splitXuguDsnParts(value: string): string[] { + const parts: string[] = []; + let current = ""; + let inQuotes = false; + + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === "'") { + current += character; + if (inQuotes && value[index + 1] === "'") { + current += value[index + 1]; + index += 1; + } else { + inQuotes = !inQuotes; + } + } else if (character === ";" && !inQuotes) { + parts.push(current); + current = ""; + } else { + current += character; + } + } + + parts.push(current); + return parts; +} + +function xuguDatabaseFromDsn(value: string): string | undefined { + let database: string | undefined; + for (const part of splitXuguDsnParts(value)) { + const separator = part.indexOf("="); + if (separator < 0 || part.slice(0, separator).trim().toLowerCase() !== "db") continue; + + let candidate = part.slice(separator + 1).trim(); + if (candidate.startsWith("'") && candidate.endsWith("'")) { + candidate = candidate.slice(1, -1).replaceAll("''", "'"); + } + database = candidate.trim() || undefined; + } + return database; +} + +export function xuguDatabaseFromConnectionString(connectionString: string | undefined): string | undefined { + const value = connectionString?.trim(); + if (!value) return undefined; + + if (/^(?:ip|ips)\s*=/i.test(value)) { + return xuguDatabaseFromDsn(value); + } + + try { + const parsed = parseConnectionUrl(value, "xugu"); + return parsed.dbType === "xugu" ? parsed.database?.trim() || undefined : undefined; + } catch { + return undefined; + } +} + +export function hasXuguConnectionDatabase(database: string | undefined, connectionString: string | undefined): boolean { + return !!database?.trim() || !!xuguDatabaseFromConnectionString(connectionString); +}