fix(mysql): refresh tables after proxy detection

Closes #6721
This commit is contained in:
t8y2
2026-08-19 18:19:04 +00:00
parent c2b7e9f269
commit 34c8afeb58
3 changed files with 186 additions and 14 deletions
@@ -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<Array<{ name: string; table_type: string; comment: null }>>((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();
+58 -14
View File
@@ -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<TableNameFilter> | 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<string, number>();
const connectionStateRevisions = new Map<string, number>();
const connectionErrorRevisions = new Map<string, number>();
const tableListSourceRevisions = new Map<string, number>();
const treeNodeLoads = new TreeNodeLoadRegistry();
const filteredObjectGroupChildrenIds = new Set<string>();
const primaryVisibleObjectRefreshInFlight = new Set<string>();
@@ -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<void> {
+1
View File
@@ -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"));