diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index adaec26922..1fe799b7cd 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -2481,6 +2481,13 @@ function isCustomCompatibleProfile() { return selectedType.value === "custom_mysql" || selectedType.value === "custom_postgres"; } +function connectionUrlPreferredProfile() { + if (selectedType.value === "oceanbase" && form.value.driver_profile === "oceanbase-oracle") { + return "oceanbase-oracle"; + } + return selectedType.value; +} + function applyProfile(val: string, preserveConnectionFields = false) { const profile = driverProfiles[val]; if (!profile) return; @@ -3870,7 +3877,7 @@ function applyConnectionUrlToForm(input: string): boolean { return true; } - const parsed = parseConnectionUrl(input, selectedType.value); + const parsed = parseConnectionUrl(input, connectionUrlPreferredProfile()); form.value = applyParsedConnectionUrl(form.value, parsed); if (form.value.db_type === "victoriametrics") { hydrateVictoriaMetricsFields(form.value.external_config); @@ -3881,7 +3888,12 @@ function applyConnectionUrlToForm(input: string): boolean { resetMeilisearchHostInput(); } oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || ""; - selectedType.value = parsed.driverProfile; + if (parsed.driverProfile === "oceanbase-oracle") { + oceanbaseSubMode.value = "oracle"; + selectedType.value = "oceanbase"; + } else { + selectedType.value = parsed.driverProfile; + } customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : ""; mongoUseUrl.value = !!parsed.useMongoUrl; if (form.value.db_type === "h2") { @@ -3936,7 +3948,7 @@ function formValueForSubmit(): Omit { return applyConnectionDraftToConfig(form.value, { ...draft, oneTime: undefined }); } - return applyParsedConnectionUrl(form.value, parseConnectionUrl(url, selectedType.value)); + return applyParsedConnectionUrl(form.value, parseConnectionUrl(url, connectionUrlPreferredProfile())); } if (form.value.db_type === "meilisearch" && hasPendingMeilisearchHostInput()) { diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts index 4ef319a2ee..4acaf3ffb5 100644 --- a/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts +++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserFieldSearch.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { createApp, nextTick, type App, type ComputedRef, type InjectionKey } from "vue"; +import { createApp, defineComponent, h, nextTick, type App, type ComputedRef, type InjectionKey } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const backend = vi.hoisted(() => ({ @@ -93,8 +93,7 @@ vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => settings, })); -vi.mock("@/components/grid/DataGrid.vue", async () => { - const { defineComponent, h } = await import("vue"); +vi.mock("@/components/grid/DataGrid.vue", () => { return { default: defineComponent({ name: "DataGridStub", diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserInfiniteScroll.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserInfiniteScroll.spec.ts index 0c4123d177..dc78d74167 100644 --- a/apps/desktop/src/components/document/__tests__/DocumentBrowserInfiniteScroll.spec.ts +++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserInfiniteScroll.spec.ts @@ -1,7 +1,8 @@ // @vitest-environment happy-dom -import { createApp, nextTick, type App } from "vue"; +import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { isDataGridPrefixAppend } from "@/lib/dataGrid/dataGridInfiniteScroll"; const backend = vi.hoisted(() => ({ documentFindDocuments: vi.fn(), @@ -77,9 +78,7 @@ vi.mock("@/stores/settingsStore", () => ({ useSettingsStore: () => settings, })); -vi.mock("@/components/grid/DataGrid.vue", async () => { - const { defineComponent, h } = await import("vue"); - const { isDataGridPrefixAppend } = await import("@/lib/dataGrid/dataGridInfiniteScroll"); +vi.mock("@/components/grid/DataGrid.vue", () => { return { default: defineComponent({ name: "DataGridStub", diff --git a/apps/desktop/src/lib/connection/connectionUrl.ts b/apps/desktop/src/lib/connection/connectionUrl.ts index 97f58184d3..35abc556a3 100644 --- a/apps/desktop/src/lib/connection/connectionUrl.ts +++ b/apps/desktop/src/lib/connection/connectionUrl.ts @@ -31,6 +31,7 @@ export type ConnectionProfile = { const SCHEME_PROFILES: Record = { mysql: { type: "mysql", profile: "mysql", label: "MySQL", defaultPort: 3306 }, + oceanbase: { type: "mysql", profile: "oceanbase", label: "OceanBase", defaultPort: 2883 }, mariadb: { type: "mysql", profile: "mariadb", label: "MariaDB", defaultPort: 3306 }, postgres: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 }, postgresql: { type: "postgres", profile: "postgres", label: "PostgreSQL", defaultPort: 5432 }, @@ -82,6 +83,13 @@ const SCHEME_PROFILES: Record = { victoriametrics: { type: "victoriametrics", profile: "victoriametrics", label: "VictoriaMetrics", defaultPort: 8428 }, }; +const OCEANBASE_ORACLE_PROFILE: ConnectionProfile = { + type: "oceanbase-oracle", + profile: "oceanbase-oracle", + label: "OceanBase Oracle Mode", + defaultPort: 2883, +}; + const HTTP_SELECTED_PROFILES: Record = { clickhouse: SCHEME_PROFILES.clickhouse, dynamodb: SCHEME_PROFILES.dynamodb, @@ -376,6 +384,9 @@ export function connectionProfileForScheme(scheme: string, preferredProfile?: st if ((normalizedScheme === "http" || normalizedScheme === "https") && normalizedPreferredProfile) { return HTTP_SELECTED_PROFILES[normalizedPreferredProfile]; } + if (normalizedScheme === "oceanbase" && normalizedPreferredProfile === "oceanbase-oracle") { + return OCEANBASE_ORACLE_PROFILE; + } // PostgreSQL-compatible products use standard PostgreSQL URLs, so keep the // selected product profile when parsing a pasted URL. if ((normalizedScheme === "postgres" || normalizedScheme === "postgresql") && (normalizedPreferredProfile === "cloudberry" || normalizedPreferredProfile === "opentenbase")) { @@ -657,6 +668,9 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa if (!input) { throw new Error("Connection URL is empty"); } + if (/^jdbc:oceanbase:(?:oracle:)?loadbalance:\/\//i.test(input)) { + throw new Error("Unsupported OceanBase JDBC URL variant: loadbalance"); + } const jdbcHive = parseJdbcHiveUrl(input); if (jdbcHive) return jdbcHive; const jdbcH2 = parseH2JdbcUrl(input); @@ -676,7 +690,8 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa const jdbcSqlServer = parseJdbcSqlServerUrl(input); if (jdbcSqlServer) return jdbcSqlServer; const isJdbcUrl = /^jdbc:/i.test(input); - const source = isJdbcUrl ? input.replace(/^jdbc:/i, "") : input; + const isOceanBaseOracleJdbc = /^jdbc:oceanbase:oracle:\/\//i.test(input); + const source = isOceanBaseOracleJdbc ? input.replace(/^jdbc:oceanbase:oracle:/i, "oceanbase:") : isJdbcUrl ? input.replace(/^jdbc:/i, "") : input; const mongoResult = parseMongoUrl(source); if (mongoResult) return mongoResult; @@ -692,7 +707,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa } const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); - const profile = connectionProfileForScheme(scheme, preferredProfile); + const profile = connectionProfileForScheme(scheme, isOceanBaseOracleJdbc ? "oceanbase-oracle" : preferredProfile); if (!profile) { throw new Error(`Unsupported connection URL scheme: ${scheme}`); } @@ -702,8 +717,8 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa const urlParamsWithoutName = stripConnectionNameParam(urlParams); const normalizedFragment = decodeUrlPart(parsed.hash.replace(/^#/, "")).trim().toLowerCase(); const parsedUrlParams = profile.type === "redis" && normalizedFragment === "insecure" ? [urlParamsWithoutName, "insecure=true"].filter(Boolean).join("&") : urlParamsWithoutName; - const mysqlCredentials = isJdbcUrl && profile.type === "mysql" ? extractMysqlCredentialParams(parsedUrlParams) : undefined; - const effectiveUrlParams = mysqlCredentials?.urlParams ?? parsedUrlParams; + const jdbcCredentials = isJdbcUrl && (profile.type === "mysql" || profile.profile === "oceanbase-oracle") ? extractMysqlCredentialParams(parsedUrlParams) : undefined; + const effectiveUrlParams = jdbcCredentials?.urlParams ?? parsedUrlParams; if (profile.type === "mongodb") { return { dbType: profile.type, @@ -738,7 +753,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa } const isMeilisearch = profile.type === "meilisearch"; - const defaultPort = isMeilisearch && scheme === "http" ? 80 : isMeilisearch && scheme === "https" ? 443 : profile.defaultPort; + const defaultPort = isJdbcUrl && scheme === "oceanbase" ? 3306 : isMeilisearch && scheme === "http" ? 80 : isMeilisearch && scheme === "https" ? 443 : profile.defaultPort; return { ...(name ? { name } : {}), @@ -748,8 +763,8 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa host: parsed.hostname, port: parsed.port ? Number(parsed.port) : defaultPort, ...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}), - username: mysqlCredentials?.username ?? decodeUrlPart(parsed.username), - password: mysqlCredentials?.password ?? decodeUrlPart(parsed.password), + username: jdbcCredentials?.username ?? decodeUrlPart(parsed.username), + password: jdbcCredentials?.password ?? decodeUrlPart(parsed.password), database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : databaseFromPath(parsed.pathname), urlParams: effectiveUrlParams, ssl: scheme === "rediss" || scheme === "https" || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)), diff --git a/packages/app-tests/connectionUrl.test.ts b/packages/app-tests/connectionUrl.test.ts index 43626aa8b5..19da035bf7 100644 --- a/packages/app-tests/connectionUrl.test.ts +++ b/packages/app-tests/connectionUrl.test.ts @@ -192,6 +192,71 @@ test("parses MySQL JDBC user and password URL params as credentials", () => { assert.equal(parsed.urlParams, "useUnicode=true&characterEncoding=UTF8&useSSL=false"); }); +test("parses OceanBase native JDBC URLs", () => { + const parsed = parseConnectionUrl("jdbc:oceanbase://127.0.0.1:2881/test"); + + assert.equal(parsed.dbType, "mysql"); + assert.equal(parsed.driverProfile, "oceanbase"); + assert.equal(parsed.driverLabel, "OceanBase"); + assert.equal(parsed.host, "127.0.0.1"); + assert.equal(parsed.port, 2881); + assert.equal(parsed.database, "test"); + assert.equal(parsed.urlParams, ""); +}); + +test("uses the OceanBase profile default port for non-JDBC URLs without an explicit port", () => { + assert.equal(parseConnectionUrl("oceanbase://127.0.0.1/test").port, 2883); +}); + +test("uses the OceanBase JDBC driver default port when the URL omits it", () => { + assert.equal(parseConnectionUrl("jdbc:oceanbase://127.0.0.1/test").port, 3306); +}); + +test("parses OceanBase JDBC user and password URL params as credentials", () => { + const parsed = parseConnectionUrl("jdbc:oceanbase://127.0.0.1:2881/test?user=root%40tenant&password=p%40ss&useUnicode=true"); + + assert.equal(parsed.username, "root@tenant"); + assert.equal(parsed.password, "p@ss"); + assert.equal(parsed.urlParams, "useUnicode=true"); +}); + +test("preserves the selected OceanBase MySQL profile for native JDBC URLs", () => { + const parsed = parseConnectionUrl("jdbc:oceanbase://127.0.0.1:2881/test", "oceanbase"); + + assert.equal(parsed.dbType, "mysql"); + assert.equal(parsed.driverProfile, "oceanbase"); + assert.equal(parsed.driverLabel, "OceanBase"); +}); + +test("preserves the selected OceanBase Oracle profile for native JDBC URLs", () => { + const parsed = parseConnectionUrl("jdbc:oceanbase://127.0.0.1:2881/sys?user=SYS&password=secret&useSSL=false", "oceanbase-oracle"); + + assert.equal(parsed.dbType, "oceanbase-oracle"); + assert.equal(parsed.driverProfile, "oceanbase-oracle"); + assert.equal(parsed.driverLabel, "OceanBase Oracle Mode"); + assert.equal(parsed.username, "SYS"); + assert.equal(parsed.password, "secret"); + assert.equal(parsed.urlParams, "useSSL=false"); +}); + +test("parses explicit OceanBase Oracle-mode JDBC URLs", () => { + const parsed = parseConnectionUrl("jdbc:oceanbase:oracle://ob.example.com/sys?user=SYS&password=secret&useSSL=false"); + + assert.equal(parsed.dbType, "oceanbase-oracle"); + assert.equal(parsed.driverProfile, "oceanbase-oracle"); + assert.equal(parsed.host, "ob.example.com"); + assert.equal(parsed.port, 3306); + assert.equal(parsed.database, "sys"); + assert.equal(parsed.username, "SYS"); + assert.equal(parsed.password, "secret"); + assert.equal(parsed.urlParams, "useSSL=false"); +}); + +test("does not parse OceanBase load-balance JDBC URLs as single-host connections", () => { + assert.throws(() => parseConnectionUrl("jdbc:oceanbase:loadbalance://host1:2881,host2:2881/test"), /loadbalance/); + assert.throws(() => parseConnectionUrl("jdbc:oceanbase:oracle:loadbalance://host1:2881,host2:2881/sys"), /loadbalance/); +}); + test("parses MySQL JDBC URL params with ProxySQL multi-at usernames", () => { const parsed = parseConnectionUrl("jdbc:mysql://127.0.0.1:6033/example?user=xxxxx%40db_readonly%40127.0.0.1&password=p%40wd&useSSL=false");