diff --git a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts index 65248d530..80ff82b25 100644 --- a/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/jdbcDialect.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "@/lib/database/jdbcDialect"; +import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect } from "@/lib/database/jdbcDialect"; describe("jdbc dialect inference", () => { it("detects InterSystems IRIS and Caché JDBC connections", () => { @@ -46,6 +46,24 @@ describe("jdbc dialect inference", () => { }), ).toBe("sqlserver"); }); + + it("detects GaussDB-compatible JDBC connections as schema-aware", () => { + const gaussdbConnection = { + db_type: "jdbc" as const, + connection_string: "jdbc:gaussdb://localhost:8000/testdb", + jdbc_driver_class: "com.huawei.gaussdb.jdbc.Driver", + }; + const opengaussConnection = { + db_type: "jdbc" as const, + connection_string: "jdbc:opengauss://localhost:5432/postgres", + jdbc_driver_class: "org.opengauss.Driver", + }; + + expect(inferJdbcDialect(gaussdbConnection)).toBe("gaussdb"); + expect(connectionUsesDatabaseObjectTreeMode(gaussdbConnection)).toBe(false); + expect(inferJdbcDialect(opengaussConnection)).toBe("opengauss"); + expect(connectionUsesDatabaseObjectTreeMode(opengaussConnection)).toBe(false); + }); }); describe("query execution schema", () => { diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index c83cb2f9f..30b4d3206 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -12,6 +12,8 @@ const JDBC_DIALECT_MATCHERS: Array<{ type: DatabaseType; patterns: RegExp[] }> = { type: "goldendb", patterns: [/jdbc:goldendb:/i, /goldendb/i] }, { type: "hive", patterns: [/org\.apache\.hive\.jdbc\.HiveDriver/i, /hive-jdbc/i] }, { type: "mysql", patterns: [/jdbc:mysql:/i, /mysql/i, /mariadb/i, /kyuubi/i, /hive2/i] }, + { type: "gaussdb", patterns: [/jdbc:gaussdb:/i, /com\.huawei\.gaussdb/i, /gaussdb/i] }, + { type: "opengauss", patterns: [/jdbc:opengauss:/i, /org\.opengauss/i, /opengauss/i] }, { type: "postgres", patterns: [/jdbc:postgresql:/i, /postgres/i] }, { type: "sqlserver", patterns: [/jdbc:sqlserver:/i, /sqlserver/i, /mssql/i] }, { type: "oracle", patterns: [/jdbc:oracle:/i, /oracle/i] }, @@ -76,6 +78,10 @@ export function connectionUsesDatabaseObjectTreeMode(connection?: JdbcDialectCon return !usesTreeSchemaMode(dialect); } +export function connectionShouldDiscoverJdbcSchemas(connection?: JdbcDialectConnection): boolean { + return connection?.db_type === "jdbc" && !inferJdbcDialect(connection); +} + export function connectionUsesSchemaExecutionContext(connection?: Pick): boolean { return connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend"; } @@ -103,7 +109,6 @@ export function metadataSchemaForConnection(connection: JdbcDialectConnection | } export function connectionObjectTreeNodeSchema(connection: JdbcDialectConnection | undefined, database: string, schema?: string): string | undefined { - if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; if (connection?.db_type === "jdbc" && inferJdbcDialect(connection) === "databend") return schema || database; if (connectionUsesDatabaseObjectTreeMode(connection)) return undefined; if (schema) return schema; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 0a52b82a2..867de18a4 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -50,6 +50,21 @@ function oracleConnection(): ConnectionConfig { } as ConnectionConfig; } +function genericJdbcConnection(): ConnectionConfig { + return { + id: "jdbc-1", + name: "Generic JDBC", + db_type: "jdbc", + host: "127.0.0.1", + port: 0, + username: "app", + password: "", + database: "testdb", + driver_profile: "jdbc", + connection_string: "jdbc:example://127.0.0.1/testdb", + } as ConnectionConfig; +} + function procedure(name: string): ObjectInfo { return { name, @@ -160,6 +175,77 @@ describe("connectionStore metadata loading", () => { expect(node.isExpanded).toBe(true); }); + it("discovers schema nodes for unknown generic JDBC databases", async () => { + const listSchemaInfos = vi.fn().mockResolvedValue([ + { name: "app", comment: null }, + { name: "reporting", comment: null }, + ]); + const listTables = vi.fn().mockResolvedValue([]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + 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 store = useConnectionStore(); + const connection = genericJdbcConnection(); + const databaseNode: TreeNode = { id: "jdbc-1:testdb", label: "testdb", type: "database", connectionId: connection.id, database: "testdb", 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, "testdb"); + expect(listTables).not.toHaveBeenCalled(); + expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([ + ["schema", "app", "app"], + ["schema", "reporting", "reporting"], + ]); + }); + + 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 }]); + + 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 = genericJdbcConnection(); + const databaseNode: TreeNode = { id: "jdbc-1:testdb", label: "testdb", type: "database", connectionId: connection.id, database: "testdb", 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, "testdb"); + expect(listTables).toHaveBeenCalled(); + expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([["table", "t", 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); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index b19bf84e2..295d2ee07 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -47,7 +47,7 @@ import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities"; -import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree"; import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree"; import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse"; @@ -3148,6 +3148,13 @@ export const useConnectionStore = defineStore("connection", () => { children: [], }; }); + if (schemas.length === 0 && connectionShouldDiscoverJdbcSchemas(getConfig(connectionId))) { + // Generic JDBC drivers vary widely: prefer schema navigation when the + // driver reports schemas, but keep the legacy flat object tree when it + // reports none so non-schema engines do not expand into an empty node. + await loadTables(connectionId, database, undefined, options); + return; + } if (isPostgresLikeForExtensions(getConfig(connectionId)?.db_type)) { children.push(buildExtensionManagementNode(connectionId, database)); } @@ -4395,7 +4402,7 @@ export const useConnectionStore = defineStore("connection", () => { const effectiveDbType = effectiveDatabaseTypeForConnection(config); if (config?.db_type === "sqlserver") { await loadSqlServerDatabaseObjects(node.connectionId, node.database, options); - } else if (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) { + } else if ((usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) || connectionShouldDiscoverJdbcSchemas(config)) { await loadSchemas(node.connectionId, node.database, options); } else { await loadTables(node.connectionId, node.database, undefined, options); diff --git a/packages/app-tests/jdbcDialect.test.ts b/packages/app-tests/jdbcDialect.test.ts index f29043564..be9b1026e 100644 --- a/packages/app-tests/jdbcDialect.test.ts +++ b/packages/app-tests/jdbcDialect.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection, inferJdbcDialect, sqlSnippetDatabaseTypeForConnection } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; +import { codeMirrorSqlDialectForConnection, connectionShouldDiscoverJdbcSchemas, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, inferJdbcDialect, sqlSnippetDatabaseTypeForConnection } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; test("infers GoldenDB for generic JDBC connections", () => { assert.equal( @@ -29,6 +29,29 @@ test("infers JDBC dialect from driver profile", () => { ); }); +test("infers GaussDB-compatible JDBC connections as schema-aware", () => { + const gaussdbConnection = { + db_type: "jdbc" as const, + connection_string: "jdbc:gaussdb://127.0.0.1:8000/testdb", + jdbc_driver_class: "com.huawei.gaussdb.jdbc.Driver", + }; + const opengaussConnection = { + db_type: "jdbc" as const, + connection_string: "jdbc:opengauss://127.0.0.1:5432/postgres", + jdbc_driver_class: "org.opengauss.Driver", + }; + + assert.equal(inferJdbcDialect(gaussdbConnection), "gaussdb"); + assert.equal(connectionUsesDatabaseObjectTreeMode(gaussdbConnection), false); + assert.equal(inferJdbcDialect(opengaussConnection), "opengauss"); + assert.equal(connectionUsesDatabaseObjectTreeMode(opengaussConnection), false); +}); + +test("discovers schemas only for unknown generic JDBC connections", () => { + assert.equal(connectionShouldDiscoverJdbcSchemas({ db_type: "jdbc", driver_profile: "jdbc" }), true); + assert.equal(connectionShouldDiscoverJdbcSchemas({ db_type: "jdbc", connection_string: "jdbc:mysql://127.0.0.1:3306/app" }), false); +}); + test("uses SQL Server editor syntax for ASE without changing its effective JDBC type", () => { const aseConnections = [ { db_type: "jdbc" as const, driver_profile: "ase" },