From 1daaac03aa8f2752055c3ed1a75ef6cf130611d5 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 9 Aug 2026 18:05:23 +0800 Subject: [PATCH] fix(jdbc): show Oracle schemas for custom drivers Closes #5638 --- .../__tests__/database/jdbcDialect.spec.ts | 17 ++++++ .../database/visibleDatabases.spec.ts | 13 ++++- apps/desktop/src/lib/database/jdbcDialect.ts | 11 +++- .../src/lib/database/visibleDatabases.ts | 25 ++++----- .../connectionStore.metadataLoading.spec.ts | 55 +++++++++++++++++++ .../connectionVisibleDatabases.test.ts | 12 ++++ packages/app-tests/jdbcDialect.test.ts | 11 ++++ 7 files changed, 128 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index 399b09eab..0cc2904e4 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -7,6 +7,7 @@ import { connectionQueryExecutionSchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, + connectionUsesConnectionRootSchemaMode, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbConnectionMode, @@ -64,6 +65,22 @@ describe("jdbc dialect inference", () => { ).toBe("sqlserver"); }); + it.each([ + ["jdbc:oracle:thin:@//localhost:1521/XE", "oracle"], + ["jdbc:dm://localhost:5236/DAMENG", "dameng"], + ] as const)("uses connection-root schemas for %s", (connectionString, dialect) => { + const connection = { + db_type: "jdbc" as const, + connection_string: connectionString, + }; + + expect(inferJdbcDialect(connection)).toBe(dialect); + expect(connectionUsesConnectionRootSchemaMode(connection)).toBe(true); + expect(connectionUsesDatabaseObjectTreeMode(connection)).toBe(false); + expect(connectionObjectTreeQuerySchema(connection, "DBX_TEST", "DBX_TEST")).toBe("DBX_TEST"); + expect(connectionObjectTreeNodeSchema(connection, "DBX_TEST", "DBX_TEST")).toBe("DBX_TEST"); + }); + it("detects GaussDB-compatible JDBC connections as schema-aware", () => { const gaussdbConnection = { db_type: "jdbc" as const, diff --git a/apps/desktop/src/lib/__tests__/database/visibleDatabases.spec.ts b/apps/desktop/src/lib/__tests__/database/visibleDatabases.spec.ts index 907b7d6a2..71be91c89 100644 --- a/apps/desktop/src/lib/__tests__/database/visibleDatabases.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/visibleDatabases.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { filterSchemaNamesForConnection, filterSchemaNamesForVisiblePicker, isSystemSchemaName } from "@/lib/database/visibleDatabases"; +import { connectionUsesVisibleSchemaFilter, filterSchemaNamesForConnection, filterSchemaNamesForVisiblePicker, isSystemSchemaName } from "@/lib/database/visibleDatabases"; describe("visibleDatabases schema filtering", () => { it("hides common Kingbase system schemas by default", () => { @@ -19,6 +19,17 @@ describe("visibleDatabases schema filtering", () => { expect(filterSchemaNamesForConnection(["DBX_TEST", "DIP", "SYSTEM"], { db_type: "oracle", database: "XE" }, "XE")).toEqual(["DBX_TEST", "DIP"]); }); + it("uses Oracle schema filtering for inferred JDBC connections", () => { + const connection = { + db_type: "jdbc" as const, + connection_string: "jdbc:oracle:thin:@//localhost:1521/XE", + username: "DBX_TEST", + }; + + expect(connectionUsesVisibleSchemaFilter(connection)).toBe(true); + expect(filterSchemaNamesForConnection(["ANONYMOUS", "DBX_TEST", "SYS", "SYSTEM"], connection, "")).toEqual(["DBX_TEST"]); + }); + it("keeps the Dameng login schema visible while hiding default system schemas", () => { expect(filterSchemaNamesForConnection(["APP", "SYS", "SYSDBA", "SYSDBO", "SYSAUDITOR"], { db_type: "dameng", username: "SYSDBA" }, "")).toEqual(["APP", "SYSDBA"]); }); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index ce34c259e..f2bb79620 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -2,7 +2,7 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; -type JdbcDialectConnection = Pick & Partial>; +type JdbcDialectConnection = Partial>; export type GaussdbIdentifierQuoteStyle = "auto" | "double" | "backtick"; export type GaussdbConnectionMode = "native" | "m-jdbc"; @@ -12,6 +12,7 @@ export const GAUSSDB_M_JDBC_DRIVER_PROFILE = "gaussdb-m"; export const GAUSSDB_M_JDBC_DRIVER_CLASS = "com.huawei.gaussdb.jdbc.Driver"; const DATABASE_AS_EXECUTION_SCHEMA_TYPES = new Set(["hive", "spark"]); +const CONNECTION_ROOT_SCHEMA_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]); const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = [ { type: "databend", patterns: [/jdbc:databend:/i, /com\.databend\.jdbc\.DatabendDriver/i, /databend-jdbc/i] }, @@ -67,6 +68,11 @@ export function effectiveDatabaseTypeForConnection(connection?: JdbcDialectConne return inferJdbcDialect(connection) ?? "jdbc"; } +export function connectionUsesConnectionRootSchemaMode(connection?: JdbcDialectConnection): boolean { + const type = effectiveDatabaseTypeForConnection(connection); + return !!type && CONNECTION_ROOT_SCHEMA_TYPES.has(type); +} + export function connectionShouldLoadIdentifierQuote(connection: JdbcDialectConnection | undefined): boolean { if (!connection) return false; if (connection.db_type === "gbase" && isGbase8sProfile(connection.driver_profile)) return true; @@ -135,6 +141,7 @@ export function tableStructureDatabaseTypeForConnection(connection?: JdbcDialect export function connectionUsesDatabaseObjectTreeMode(connection?: JdbcDialectConnection): boolean { if (!connection) return false; if (connection.db_type !== "jdbc") return usesDatabaseObjectTreeMode(effectiveDatabaseTypeForConnection(connection)); + if (connectionUsesConnectionRootSchemaMode(connection)) return false; const dialect = inferJdbcDialect(connection); if (!dialect) return true; if (dialect === "hive" || dialect === "trino") return false; @@ -149,7 +156,7 @@ export function connectionShouldDiscoverJdbcSchemas(connection?: JdbcDialectConn return connection?.db_type === "jdbc" && !inferJdbcDialect(connection); } -export function connectionUsesSchemaExecutionContext(connection?: Pick): boolean { +export function connectionUsesSchemaExecutionContext(connection?: JdbcDialectConnection): boolean { return connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend"; } diff --git a/apps/desktop/src/lib/database/visibleDatabases.ts b/apps/desktop/src/lib/database/visibleDatabases.ts index e0607bdec..f4c7ce9c6 100644 --- a/apps/desktop/src/lib/database/visibleDatabases.ts +++ b/apps/desktop/src/lib/database/visibleDatabases.ts @@ -1,4 +1,7 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import { connectionUsesConnectionRootSchemaMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; + +type VisibleDatabaseConnection = Partial; type SystemNameRules = { exact?: ReadonlySet; @@ -171,7 +174,7 @@ export function isSystemSchemaName(databaseType: DatabaseType | undefined, schem return rules.prefixes?.some((prefix) => normalized.startsWith(prefix)) ?? false; } -export function filterDatabaseNamesForConnection(databaseNames: string[], connection: Pick | undefined): string[] { +export function filterDatabaseNamesForConnection(databaseNames: string[], connection: VisibleDatabaseConnection | undefined): string[] { const visibleDatabases = connection?.visible_databases; if (visibleDatabaseFilterIsEnabled(visibleDatabases)) { return filterVisibleDatabaseNames(databaseNames, visibleDatabases); @@ -179,33 +182,29 @@ export function filterDatabaseNamesForConnection(databaseNames: string[], connec return filterDatabaseNamesForVisiblePicker(databaseNames, connection); } -export function filterDatabaseNamesForVisiblePicker(databaseNames: string[], connection: Pick | undefined): string[] { +export function filterDatabaseNamesForVisiblePicker(databaseNames: string[], connection: VisibleDatabaseConnection | undefined): string[] { if (connection?.db_type === "gbase" && connection.driver_profile === "gbase8s") { return databaseNames; } - return databaseNames.filter((name) => !isSystemDatabaseName(connection?.db_type, name)); + return databaseNames.filter((name) => !isSystemDatabaseName(effectiveDatabaseTypeForConnection(connection), name)); } -export function filterSchemaNamesForVisiblePicker(schemaNames: string[], connection: Partial> | undefined, options?: SchemaFilterOptions): string[] { +export function filterSchemaNamesForVisiblePicker(schemaNames: string[], connection: VisibleDatabaseConnection | undefined, options?: SchemaFilterOptions): string[] { if (schemaFilterShowSystemSchemas(connection, options)) return schemaNames; const currentSchema = connection?.username?.trim().toLowerCase(); - return schemaNames.filter((name) => name.toLowerCase() === currentSchema || !isSystemSchemaName(connection?.db_type, name)); + const databaseType = effectiveDatabaseTypeForConnection(connection); + return schemaNames.filter((name) => name.toLowerCase() === currentSchema || !isSystemSchemaName(databaseType, name)); } -export function connectionUsesVisibleSchemaFilter(connection: Pick | undefined): boolean { - return connection?.db_type === "oracle" || connection?.db_type === "dameng" || connection?.db_type === "oceanbase-oracle"; +export function connectionUsesVisibleSchemaFilter(connection: VisibleDatabaseConnection | undefined): boolean { + return connectionUsesConnectionRootSchemaMode(connection); } export function visibleSchemaFilterIsEnabled(visibleSchemas: Record | undefined, database: string): boolean { return Array.isArray(visibleSchemas?.[database]); } -export function filterSchemaNamesForConnection( - schemaNames: string[], - connection: (Pick & Partial>) | undefined, - database: string, - options?: SchemaFilterOptions, -): string[] { +export function filterSchemaNamesForConnection(schemaNames: string[], connection: VisibleDatabaseConnection | undefined, database: string, options?: SchemaFilterOptions): string[] { const visibleSchemas = connection?.visible_schemas; if (!visibleSchemaFilterIsEnabled(visibleSchemas, database)) { if (connectionUsesVisibleSchemaFilter(connection) && visibleDatabaseFilterIsEnabled(connection?.visible_databases)) { diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 2bb9be0ce..0a7e616ed 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -75,6 +75,18 @@ function genericJdbcConnection(): ConnectionConfig { } as ConnectionConfig; } +function oracleJdbcConnection(): ConnectionConfig { + return { + ...genericJdbcConnection(), + id: "jdbc-oracle-1", + name: "Oracle JDBC", + username: "DBX_TEST", + database: undefined, + connection_string: "jdbc:oracle:thin:@//127.0.0.1:1521/XE", + jdbc_driver_class: "oracle.jdbc.OracleDriver", + } as ConnectionConfig; +} + function informixConnection(): ConnectionConfig { return { id: "informix-1", @@ -247,6 +259,49 @@ describe("connectionStore metadata loading", () => { ]); }); + it("loads inferred Oracle JDBC schemas without requesting empty catalogs", async () => { + const listDatabases = vi.fn().mockResolvedValue([]); + const listSchemas = vi.fn().mockResolvedValue(["ANONYMOUS", "DBX_TEST", "SYS", "SYSTEM"]); + const listTables = vi.fn().mockResolvedValue([{ name: "sheet", 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), + listDatabases, + listObjects: vi.fn().mockResolvedValue([]), + listSchemas, + 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 = oracleJdbcConnection(); + const connectionNode: TreeNode = { id: connection.id, label: connection.name, type: "connection", connectionId: connection.id, isExpanded: false, children: [] }; + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [connectionNode]; + + await store.loadDatabases(connection.id, { force: true }); + + expect(listDatabases).not.toHaveBeenCalled(); + expect(listSchemas).toHaveBeenCalledWith(connection.id, ""); + expect(connectionNode.children?.map((node) => [node.type, node.label, node.database, node.schema])).toEqual([["schema", "DBX_TEST", "DBX_TEST", "DBX_TEST"]]); + + const schemaNode = connectionNode.children?.[0]; + expect(schemaNode).toBeDefined(); + await store.loadTreeNodeChildren(schemaNode!, { force: true }); + + expect(listTables.mock.calls[0]?.slice(0, 3)).toEqual([connection.id, "DBX_TEST", "DBX_TEST"]); + expect(schemaNode?.children?.map((node) => [node.type, node.label, node.schema])).toEqual([["table", "sheet", "DBX_TEST"]]); + }); + it("keeps the flat object tree for unknown generic JDBC databases without schemas", async () => { const listSchemaInfos = vi.fn().mockResolvedValue([]); const listTables = vi.fn().mockResolvedValue([{ name: "t", table_type: "TABLE", comment: null }]); diff --git a/packages/app-tests/connectionVisibleDatabases.test.ts b/packages/app-tests/connectionVisibleDatabases.test.ts index 198b562f8..c3067575d 100644 --- a/packages/app-tests/connectionVisibleDatabases.test.ts +++ b/packages/app-tests/connectionVisibleDatabases.test.ts @@ -100,6 +100,18 @@ test("OceanBase Oracle uses schema filtering for visible object selection", () = assert.equal(connectionUsesVisibleSchemaFilter(config({ db_type: "mysql", driver_profile: "oceanbase" })), false); }); +test("Oracle JDBC uses schema filtering for visible object selection", () => { + const connection = config({ + db_type: "jdbc", + driver_profile: "jdbc", + connection_string: "jdbc:oracle:thin:@//127.0.0.1:1521/XE", + username: "DBX_TEST", + }); + + assert.equal(connectionUsesVisibleSchemaFilter(connection), true); + assert.deepEqual(filterSchemaNamesForConnection(["ANONYMOUS", "DBX_TEST", "SYS", "SYSTEM"], connection, ""), ["DBX_TEST"]); +}); + test("Vastbase schema filters preserve ordinary schemas and explicit empty selections", () => { const schemas = ["public", "app"]; assert.deepEqual(filterSchemaNamesForConnection(schemas, config({ db_type: "vastbase", database: "vastbase" }), "vastbase"), schemas); diff --git a/packages/app-tests/jdbcDialect.test.ts b/packages/app-tests/jdbcDialect.test.ts index 31daaf216..b976a448f 100644 --- a/packages/app-tests/jdbcDialect.test.ts +++ b/packages/app-tests/jdbcDialect.test.ts @@ -4,6 +4,7 @@ import { codeMirrorSqlDialectForConnection, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, + connectionUsesConnectionRootSchemaMode, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride, @@ -41,6 +42,16 @@ test("infers JDBC dialect from driver profile", () => { ); }); +test("Oracle JDBC uses connection-root schema navigation", () => { + const connection = { + db_type: "jdbc" as const, + connection_string: "jdbc:oracle:thin:@//127.0.0.1:1521/XE", + }; + + assert.equal(connectionUsesConnectionRootSchemaMode(connection), true); + assert.equal(connectionUsesDatabaseObjectTreeMode(connection), false); +}); + test("uses dedicated ClickHouse editor syntax for inferred JDBC connections", () => { const connection = { db_type: "jdbc" as const,