diff --git a/apps/desktop/src/stores/__tests__/connectionStore.databaseInfo.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.databaseInfo.spec.ts index eced1d629c..ce51bec868 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.databaseInfo.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.databaseInfo.spec.ts @@ -159,6 +159,133 @@ describe("connectionStore database info", () => { await vi.waitFor(() => expect(store.getConfig(config.id)?.database_info?.productVersion).toBe("8.0.34")); }); + it("refreshes physical table metadata after delayed ShardingSphere detection", async () => { + const config = mysqlConnection(); + let resolveDatabaseInfo!: (value: { productName: string; productVersion: string }) => void; + const databaseInfo = new Promise<{ productName: string; productVersion: string }>((resolve) => { + resolveDatabaseInfo = resolve; + }); + let useLogicalTables = false; + const listTables = vi.fn().mockImplementation(async () => [ + { + name: useLogicalTables ? "async_task" : "async_task_0", + table_type: "BASE TABLE", + comment: null, + }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + connectDb: vi.fn().mockResolvedValue(config.id), + connectionDatabaseInfo: vi.fn(() => databaseInfo), + connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases: vi.fn().mockResolvedValue([{ name: "app" }]), + listObjects: vi.fn().mockResolvedValue([]), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnectionDatabaseInfo: vi.fn().mockImplementation(async () => { + useLogicalTables = true; + }), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: 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"; + await store.addConnection(config); + await store.connect(config); + await store.loadDatabases(config.id, { force: true }); + const databaseNode = store.treeNodes[0]?.children?.find((node) => node.type === "database" && node.database === "app"); + expect(databaseNode).toBeDefined(); + await store.loadTreeNodeChildren(databaseNode!, { force: true }); + expect(databaseNode?.children?.filter((node) => node.type === "table").map((node) => node.label)).toEqual(["async_task_0"]); + + resolveDatabaseInfo({ productName: "MySQL", productVersion: "8.0.27-ShardingSphere-Proxy 5.5.2" }); + + await vi.waitFor(() => { + const refreshedDatabaseNode = store.treeNodes[0]?.children?.find((node) => node.type === "database" && node.database === "app"); + expect(refreshedDatabaseNode?.children?.filter((node) => node.type === "table").map((node) => node.label)).toEqual(["async_task"]); + }); + expect(listTables).toHaveBeenCalledTimes(2); + }); + + it("does not let a pre-detection table request overwrite the refreshed logical tables", async () => { + const config = mysqlConnection(); + let resolveDatabaseInfo!: (value: { productName: string; productVersion: string }) => void; + const databaseInfo = new Promise<{ productName: string; productVersion: string }>((resolve) => { + resolveDatabaseInfo = resolve; + }); + let resolvePhysicalTables!: (value: Array<{ name: string; table_type: string; comment: null }>) => void; + const physicalTables = new Promise>((resolve) => { + resolvePhysicalTables = resolve; + }); + let useLogicalTables = false; + const listTables = vi + .fn() + .mockImplementationOnce(() => physicalTables) + .mockResolvedValue([ + { + name: "async_task", + table_type: "BASE TABLE", + comment: null, + }, + ]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + connectDb: vi.fn().mockResolvedValue(config.id), + connectionDatabaseInfo: vi.fn(() => databaseInfo), + connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + listDatabases: vi.fn().mockResolvedValue([{ name: "app" }]), + listObjects: vi.fn().mockResolvedValue([]), + listTables, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveConnectionDatabaseInfo: vi.fn().mockImplementation(async () => { + useLogicalTables = true; + }), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSchemaCache: 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"; + await store.addConnection(config); + await store.connect(config); + await store.loadDatabases(config.id, { force: true }); + const databaseNode = store.treeNodes[0]?.children?.find((node) => node.type === "database" && node.database === "app"); + expect(databaseNode).toBeDefined(); + databaseNode!.isExpanded = true; + const physicalLoad = store.loadTreeNodeChildren(databaseNode!, { force: true }); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(1)); + + resolveDatabaseInfo({ productName: "MySQL", productVersion: "8.0.27-ShardingSphere-Proxy 5.5.2" }); + await vi.waitFor(() => expect(listTables).toHaveBeenCalledTimes(2)); + resolvePhysicalTables([ + { + name: "async_task_0", + table_type: "BASE TABLE", + comment: null, + }, + ]); + await physicalLoad; + + await vi.waitFor(() => { + const refreshedDatabaseNode = store.treeNodes[0]?.children?.find((node) => node.type === "database" && node.database === "app"); + expect(refreshedDatabaseNode?.children?.filter((node) => node.type === "table").map((node) => node.label)).toEqual(["async_task"]); + }); + expect(useLogicalTables).toBe(true); + }); + it("keeps a successful connection when optional metadata refresh fails", async () => { const config = mysqlConnection(); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index c78ea6fa49..87b970b26e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -165,6 +165,15 @@ const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy"; const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)"; const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23"; const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt"; +const SHARDINGSPHERE_PROXY_VERSION_MARKER = "shardingsphere-proxy"; + +function usesShardingSphereLogicalTables(databaseInfo: DatabaseConnectionInfo | undefined): boolean { + return databaseInfo?.productVersion?.toLowerCase().includes(SHARDINGSPHERE_PROXY_VERSION_MARKER) === true; +} + +function mysqlTableListSourceChanged(config: ConnectionConfig, databaseInfo: DatabaseConnectionInfo): boolean { + return config.db_type === "mysql" && usesShardingSphereLogicalTables(config.database_info) !== usesShardingSphereLogicalTables(databaseInfo); +} function normalizeTableNameFilter(filter: Partial | undefined | null): TableNameFilter { const normalizePatterns = (patterns: unknown): string[] => (Array.isArray(patterns) ? patterns.map((pattern) => (typeof pattern === "string" ? pattern.trim() : "")).filter(Boolean) : []); @@ -514,6 +523,7 @@ export const useConnectionStore = defineStore("connection", () => { const successfulLocalConnectionAttempts = new Map(); const connectionStateRevisions = new Map(); const connectionErrorRevisions = new Map(); + const tableListSourceRevisions = new Map(); const treeNodeLoads = new TreeNodeLoadRegistry(); const filteredObjectGroupChildrenIds = new Set(); const primaryVisibleObjectRefreshInFlight = new Set(); @@ -1646,13 +1656,24 @@ export const useConnectionStore = defineStore("connection", () => { }); } - function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined): MetadataScopeInput["extra"] { - return filter - ? { - tableNameFilterInclude: filter.includePatterns, - tableNameFilterExclude: filter.excludePatterns, - } - : undefined; + function tableListSourceRevision(connectionId: string): number { + return tableListSourceRevisions.get(connectionId) ?? 0; + } + + function bumpTableListSourceRevision(connectionId: string): void { + tableListSourceRevisions.set(connectionId, tableListSourceRevision(connectionId) + 1); + } + + function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined, sourceRevision: number): MetadataScopeInput["extra"] { + return { + tableListSourceRevision: sourceRevision, + ...(filter + ? { + tableNameFilterInclude: filter.includePatterns, + tableNameFilterExclude: filter.excludePatterns, + } + : {}), + }; } function setSidebarTableNameFilter(scopeKey: string, filter: TableNameFilter) { @@ -1888,6 +1909,7 @@ export const useConnectionStore = defineStore("connection", () => { catalog: options.node.catalog, }); const tableNameFilter = effectiveTableNameFilterForNode(options.node, userTableNameFilter); + const sourceRevision = tableListSourceRevision(options.node.connectionId); // A search must never truncate the fuzzy result set to the first page: the // target table can sort beyond it (e.g. "T_Erp_Nc_SuPlan_List" for // "erpncs" in a large ERP schema), which silently drops it from the first @@ -1909,14 +1931,16 @@ export const useConnectionStore = defineStore("connection", () => { limit: fetchLimit, offset: fetchOffset, sidebarDisplayMode: "grouped", - extra: tableNameFilterMetadataExtra(tableNameFilter), + extra: tableNameFilterMetadataExtra(tableNameFilter, sourceRevision), }), () => listTablesWithOptionalTableNameFilter(options.node.connectionId!, options.node.database!, options.querySchema, searchFilter, fetchLimit, fetchOffset, options.objectTypes, options.node.catalog, tableNameFilter), { force: options.force }, ); const hasMore = searchFilter ? false : tables.length > options.pageSize; const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; - indexCompletionTables(options.node.connectionId, options.node.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); + if (tableListSourceRevision(options.node.connectionId) === sourceRevision) { + indexCompletionTables(options.node.connectionId, options.node.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); + } const objects = mergeTableInfosIntoObjects([], pageTables, options.effectiveSchema); const children = objectGroupChildrenFromObjects({ node: options.node, @@ -2004,6 +2028,7 @@ export const useConnectionStore = defineStore("connection", () => { schema: options.effectiveSchema ?? options.querySchema, nodeKind: "simple-tables", }); + const sourceRevision = tableListSourceRevision(options.connectionId); // A search must never truncate the fuzzy result set to the first page (see // loadPagedTableGroupChildren); results are bounded by // SIDEBAR_TABLE_SEARCH_RESULT_BUDGET, and unfiltered loads keep the @@ -2021,14 +2046,16 @@ export const useConnectionStore = defineStore("connection", () => { limit: fetchLimit, offset: fetchOffset, sidebarDisplayMode: "simple", - extra: tableNameFilterMetadataExtra(tableNameFilter), + extra: tableNameFilterMetadataExtra(tableNameFilter, sourceRevision), }), () => listTablesWithOptionalTableNameFilter(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, fetchOffset, undefined, undefined, tableNameFilter), { force: options.force }, ); const hasMore = searchFilter ? false : tables.length > options.pageSize; const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables; - indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); + if (tableListSourceRevision(options.connectionId) === sourceRevision) { + indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema)); + } const children = buildTableTreeNodes({ nodeId: options.nodeId, @@ -2878,6 +2905,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!current) return; if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return; if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return; + const tableListSourceChanged = mysqlTableListSourceChanged(current, normalized); await api.saveConnectionDatabaseInfo(connectionId, normalized); const index = connections.value.findIndex((connection) => connection.id === connectionId); @@ -2886,9 +2914,25 @@ export const useConnectionStore = defineStore("connection", () => { const nextConnections = [...connections.value]; nextConnections[index] = { ...nextConnections[index], database_info: normalized }; connections.value = nextConnections; - // Database info is reactive connection metadata, not tree structure. Keep - // navigator node identities stable so an in-flight first expansion can - // still apply its loaded children after this background refresh completes. + if (tableListSourceChanged) { + // ShardingSphere switches table enumeration from information_schema to SHOW FULL TABLES. + // Discard physical-table metadata that may have loaded before background version detection completed. + bumpTableListSourceRevision(connectionId); + treeNodeLoads.cancelPrefix(connectionId); + invalidateCompletionCache(connectionId); + void invalidateSidebarTableSearchIndexesForConnection(connectionId); + const connectionNode = findConnectionNode(connectionId); + if (connectionNode?.isExpanded && connectedIds.value.has(connectionId)) { + void refreshTreeNode(connectionNode).catch((error) => { + console.debug("[DBX][connection-info:table-metadata-refresh-failed]", { connectionId, error }); + }); + } else { + clearLoadedChildrenCache(connectionId); + } + } + // Other database info is reactive connection metadata, not tree structure. Keep + // navigator node identities stable so an in-flight first expansion can apply + // its loaded children after this background refresh completes. } async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index ff6d3a6ee6..580e44e59f 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -3864,6 +3864,7 @@ for line in sys.stdin: #[test] fn shardingsphere_proxy_marker_is_ascii_case_insensitive_and_exact() { assert!(super::is_shardingsphere_proxy_version("5.7.22-ShardingSphere-Proxy 5.5.2")); + assert!(super::is_shardingsphere_proxy_version("8.0.27-ShardingSphere-Proxy 5.5.2")); assert!(super::is_shardingsphere_proxy_version("8.0.36-SHARDINGSPHERE-PROXY 5.5.2")); assert!(!super::is_shardingsphere_proxy_version("8.0.36-ShardingSphere Proxy 5.5.2")); assert!(!super::is_shardingsphere_proxy_version("8.0.36-MySQL Community Server"));