🐛 fix(kingbase): 修复开表与 ER 图加载缓慢问题

- 解耦 Kingbase 首屏查询与编辑定位元数据加载
- 使用单次全库外键快照替代 ER 图逐表扫描
- 补充大规模表结构和异步元数据回归测试

Fixes #731
Fixes #732
This commit is contained in:
Syngnat
2026-07-27 20:39:08 +08:00
parent 999218dccc
commit 770243e0da
11 changed files with 524 additions and 37 deletions

View File

@@ -165,6 +165,52 @@ describe('DataViewer safe editing locator', () => {
renderer!.unmount();
});
it('does not block the initial Kingbase table query on edit-locator metadata', async () => {
storeState.connections[0].config.type = 'kingbase';
storeState.connections[0].config.database = 'ldf_server_dbs_dev';
let resolveColumns!: (value: any) => void;
let resolveIndexes!: (value: any) => void;
backendApp.DBGetColumns.mockReturnValue(new Promise((resolve) => {
resolveColumns = resolve;
}));
backendApp.DBGetIndexes.mockReturnValue(new Promise((resolve) => {
resolveIndexes = resolve;
}));
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<DataViewer tab={createTab({
id: 'tab-kingbase-fast-open',
dbName: 'ldf_server_dbs_dev',
tableName: 'ldf_server.andon_dash_events',
title: 'andon_dash_events',
})} />);
});
await flushPromises();
expect(backendApp.DBQuery).toHaveBeenCalled();
expect(dataGridState.latestProps?.data).toEqual([
expect.objectContaining({ ID: 7, NAME: 'old-name' }),
]);
await act(async () => {
resolveColumns({
success: true,
data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }],
});
resolveIndexes({ success: true, data: [] });
});
await flushPromises();
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'primary-key',
columns: ['ID'],
readOnly: false,
});
renderer!.unmount();
});
it('enables table preview editing after primary keys are loaded', async () => {
backendApp.DBGetColumns.mockResolvedValue({
success: true,

View File

@@ -644,24 +644,28 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (pkKeyRef.current !== locatorKey || !editLocatorForQuery) {
pkKeyRef.current = locatorKey;
const locatorSeq = ++pkSeqRef.current;
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
]);
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
const loadEditLocator = async (): Promise<{
primaryKeys: string[];
locator: EditRowLocator;
} | null> => {
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
]);
if (fetchSeqRef.current !== seq) return null;
if (pkSeqRef.current !== locatorSeq) return null;
if (pkKeyRef.current !== locatorKey) return null;
if (!resCols?.success || !Array.isArray(resCols.data)) {
const nextLocator = buildAllColumnsLocator([], { translate: tr });
setPkColumns([]);
setEditLocator(nextLocator);
if (nextLocator.reason) message.info(nextLocator.reason);
return { primaryKeys: [], locator: nextLocator };
}
if (!resCols?.success || !Array.isArray(resCols.data)) {
const nextLocator = buildAllColumnsLocator([], { translate: tr });
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
if (nextLocator.reason) message.info(nextLocator.reason);
} else {
const columnDefs = resCols.data as ColumnDefinition[];
const primaryKeys = columnDefs
.filter((column: any) => getColumnDefinitionKey(column) === 'PRI')
@@ -686,8 +690,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
translate: tr,
}), tr);
pkColumnsForQuery = primaryKeys;
editLocatorForQuery = nextLocator;
setPkColumns(primaryKeys);
setEditLocator(nextLocator);
if (nextLocator.readOnly) {
@@ -695,17 +697,28 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
} else if (nextLocator.strategy === 'all-columns' && nextLocator.reason) {
message.info(nextLocator.reason);
}
return { primaryKeys, locator: nextLocator };
} catch {
if (fetchSeqRef.current !== seq) return null;
if (pkSeqRef.current !== locatorSeq) return null;
if (pkKeyRef.current !== locatorKey) return null;
const nextLocator = buildAllColumnsLocator([], { translate: tr });
setPkColumns([]);
setEditLocator(nextLocator);
if (nextLocator.reason) message.info(nextLocator.reason);
return { primaryKeys: [], locator: nextLocator };
}
} catch {
if (fetchSeqRef.current !== seq) return;
if (pkSeqRef.current !== locatorSeq) return;
if (pkKeyRef.current !== locatorKey) return;
const nextLocator = buildAllColumnsLocator([], { translate: tr });
pkColumnsForQuery = [];
editLocatorForQuery = nextLocator;
setPkColumns([]);
setEditLocator(nextLocator);
if (nextLocator.reason) message.info(nextLocator.reason);
};
if (dbTypeLower === 'kingbase') {
// Kingbase catalog metadata can be noticeably slower than the page query.
// Keep the grid read-only briefly and enable editing when the locator arrives.
void loadEditLocator();
} else {
const locatorMetadata = await loadEditLocator();
if (!locatorMetadata) return;
pkColumnsForQuery = locatorMetadata.primaryKeys;
editLocatorForQuery = locatorMetadata.locator;
}
}
}

View File

@@ -1,11 +1,13 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ForeignKeyDefinition } from '../types';
import type { ErDiagramTableSnapshot } from './dataGridErDiagramModel';
import { collectErDiagramNeighborhood, useDataGridErDiagram } from './useDataGridErDiagram';
const backendApp = vi.hoisted(() => ({
DBGetColumns: vi.fn(),
DBGetDatabaseForeignKeys: vi.fn(),
DBGetForeignKeys: vi.fn(),
DBGetIndexes: vi.fn(),
DBGetTables: vi.fn(),
@@ -128,12 +130,62 @@ describe('collectErDiagramNeighborhood', () => {
);
expect(twoHop.canExpandRelations).toBe(false);
});
it('uses a prefetched foreign-key snapshot instead of scanning every table', async () => {
const unrelatedTables = Array.from({ length: 500 }, (_, index) => `unrelated_${index}`);
const schemaTableNames = ['orders', 'customers', 'order_items', ...unrelatedTables];
const loadSnapshot = vi.fn(async (tableName: string) => ({
tableName,
columns: [],
foreignKeys: [],
uniqueKeyGroups: [],
}));
const loadForeignKeys = vi.fn(async () => []);
const prefetchedForeignKeysByTable = new Map<string, ForeignKeyDefinition[]>(
schemaTableNames.map((tableName) => [tableName, []]),
);
prefetchedForeignKeysByTable.set('orders', [{
name: 'fk_orders_customer',
columnName: 'customer_id',
refTableName: 'customers',
refColumnName: 'id',
constraintName: 'fk_orders_customer',
}]);
prefetchedForeignKeysByTable.set('order_items', [{
name: 'fk_items_order',
columnName: 'order_id',
refTableName: 'orders',
refColumnName: 'id',
constraintName: 'fk_items_order',
}]);
const result = await collectErDiagramNeighborhood({
currentSnapshot: {
tableName: 'orders',
columns: [],
foreignKeys: [],
uniqueKeyGroups: [],
},
schemaTableNames,
relationDepth: 1,
loadSnapshot,
loadForeignKeys,
resolveTableName: (tableName) => tableName,
prefetchedForeignKeysByTable,
});
expect(result.relations.map((relation) => `${relation.sourceTableName}->${relation.targetTableName}`)).toEqual(
expect.arrayContaining(['orders->customers', 'order_items->orders']),
);
expect(loadForeignKeys).not.toHaveBeenCalled();
});
});
describe('useDataGridErDiagram cache invalidation', () => {
beforeEach(() => {
Object.values(backendApp).forEach((mock) => mock.mockReset());
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetDatabaseForeignKeys.mockResolvedValue({ success: true, data: {} });
backendApp.DBGetForeignKeys.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [{ table: 'orders' }] });
@@ -201,4 +253,50 @@ describe('useDataGridErDiagram cache invalidation', () => {
renderer?.unmount();
});
});
it('loads one Kingbase foreign-key snapshot instead of querying every table', async () => {
const unrelatedTables = Array.from({ length: 500 }, (_, index) => ({
table: `ldf_server.unrelated_${index}`,
}));
backendApp.DBGetTables.mockResolvedValue({
success: true,
data: [{ table: 'ldf_server.orders' }, ...unrelatedTables],
});
let controller: ReturnType<typeof useDataGridErDiagram> | null = null;
let renderer: ReactTestRenderer | null = null;
const params = {
connections: [{
id: 'kingbase-er-snapshot-test',
config: {
type: 'kingbase',
host: '127.0.0.1',
port: 54321,
database: 'ldf_server_dbs_dev',
},
}],
connectionId: 'kingbase-er-snapshot-test',
dbName: 'ldf_server_dbs_dev',
tableName: 'ldf_server.orders',
};
const Harness = () => {
controller = useDataGridErDiagram(params);
return null;
};
await act(async () => {
renderer = create(React.createElement(Harness));
await vi.waitFor(() => {
expect(controller?.loading).toBe(false);
expect(controller?.graph).not.toBeNull();
});
});
expect(backendApp.DBGetDatabaseForeignKeys).toHaveBeenCalledTimes(1);
expect(backendApp.DBGetForeignKeys).not.toHaveBeenCalled();
act(() => {
renderer?.unmount();
});
});
});

View File

@@ -1,9 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DBGetColumns, DBGetForeignKeys, DBGetIndexes, DBGetTables } from '../../wailsjs/go/app/App';
import {
DBGetColumns,
DBGetDatabaseForeignKeys,
DBGetForeignKeys,
DBGetIndexes,
DBGetTables,
} from '../../wailsjs/go/app/App';
import type { ColumnDefinition, ForeignKeyDefinition } from '../types';
import { createBoundedAsyncCache } from '../utils/boundedAsyncCache';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { normalizeColumnDefinitions } from '../utils/columnDefinition';
import { resolveDataSourceType } from '../utils/dataSourceCapabilities';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import {
buildErDiagramGraph,
@@ -38,6 +45,7 @@ const ER_SCHEMA_CACHE_MAX_ENTRIES = 32;
const ER_TABLE_METADATA_CACHE_MAX_ENTRIES = 256;
const schemaTableNamesCache = createBoundedAsyncCache<string[]>(ER_SCHEMA_CACHE_MAX_ENTRIES);
const databaseForeignKeysCache = createBoundedAsyncCache<Map<string, ForeignKeyDefinition[]>>(ER_SCHEMA_CACHE_MAX_ENTRIES);
const tableColumnsCache = createBoundedAsyncCache<ColumnDefinition[]>(ER_TABLE_METADATA_CACHE_MAX_ENTRIES);
const tableForeignKeysCache = createBoundedAsyncCache<ForeignKeyDefinition[]>(ER_TABLE_METADATA_CACHE_MAX_ENTRIES);
const tableUniqueKeyGroupsCache = createBoundedAsyncCache<string[][]>(ER_TABLE_METADATA_CACHE_MAX_ENTRIES);
@@ -62,7 +70,13 @@ const normalizeConnectionConfig = (connection: any) => ({
});
const invalidateCacheByPrefix = (prefix: string) => {
[schemaTableNamesCache, tableColumnsCache, tableForeignKeysCache, tableUniqueKeyGroupsCache].forEach((cache) => {
[
schemaTableNamesCache,
databaseForeignKeysCache,
tableColumnsCache,
tableForeignKeysCache,
tableUniqueKeyGroupsCache,
].forEach((cache) => {
cache.invalidatePrefix(prefix);
});
};
@@ -139,6 +153,27 @@ const loadTableForeignKeys = async (
return normalizeForeignKeyDefinitions(response.data);
});
const loadDatabaseForeignKeys = async (
config: any,
dbName: string,
cacheKey: string,
): Promise<Map<string, ForeignKeyDefinition[]>> => databaseForeignKeysCache.getOrLoad(cacheKey, async () => {
const response = await DBGetDatabaseForeignKeys(buildRpcConnectionConfig(config) as any, dbName);
if (!response?.success || !response.data || typeof response.data !== 'object' || Array.isArray(response.data)) {
throw new Error(response?.message || 'Failed to load database foreign keys');
}
const result = new Map<string, ForeignKeyDefinition[]>();
Object.entries(response.data as Record<string, unknown>).forEach(([sourceTableName, rawForeignKeys]) => {
const key = normalizeErQualifiedName(sourceTableName);
if (!key) {
return;
}
result.set(key, normalizeForeignKeyDefinitions(rawForeignKeys));
});
return result;
});
const loadTableUniqueKeyGroups = async (
config: any,
dbName: string,
@@ -157,10 +192,12 @@ const loadTableSnapshot = async (
dbName: string,
tableName: string,
tableCacheKey: string,
prefetchedForeignKeys?: Promise<ForeignKeyDefinition[]>,
): Promise<ErDiagramTableSnapshot> => {
const [columnsResult, foreignKeysResult, uniqueKeyGroupsResult] = await Promise.allSettled([
loadTableColumns(config, dbName, tableName, `${tableCacheKey}|columns`),
loadTableForeignKeys(config, dbName, tableName, `${tableCacheKey}|foreignKeys`),
prefetchedForeignKeys
|| loadTableForeignKeys(config, dbName, tableName, `${tableCacheKey}|foreignKeys`),
loadTableUniqueKeyGroups(config, dbName, tableName, `${tableCacheKey}|uniqueKeys`),
]);
@@ -225,6 +262,7 @@ type CollectErDiagramNeighborhoodParams = {
loadSnapshot: (tableName: string) => Promise<ErDiagramTableSnapshot>;
loadForeignKeys: (tableName: string) => Promise<ForeignKeyDefinition[]>;
resolveTableName: (tableName: string) => string;
prefetchedForeignKeysByTable?: ReadonlyMap<string, ForeignKeyDefinition[]>;
};
type CollectErDiagramNeighborhoodResult = {
@@ -263,14 +301,25 @@ export const collectErDiagramNeighborhood = async (
registerTableName(currentTableName);
params.schemaTableNames.forEach(registerTableName);
params.prefetchedForeignKeysByTable?.forEach((foreignKeys, tableName) => {
const actualTableName = registerTableName(tableName);
const tableKey = normalizeErQualifiedName(actualTableName);
if (tableKey) {
foreignKeysByKey.set(tableKey, foreignKeys);
}
});
params.currentSnapshot.foreignKeys.forEach((foreignKey) => {
registerRelationTarget(foreignKey.refTableName);
});
const currentForeignKeys = foreignKeysByKey.has(currentKey)
? foreignKeysByKey.get(currentKey) || []
: params.currentSnapshot.foreignKeys || [];
snapshotByKey.set(currentKey, {
...params.currentSnapshot,
tableName: registerTableName(params.currentSnapshot.tableName),
foreignKeys: currentForeignKeys,
});
foreignKeysByKey.set(currentKey, params.currentSnapshot.foreignKeys || []);
foreignKeysByKey.set(currentKey, currentForeignKeys);
visitedKeys.add(currentKey);
const loadSnapshotByKey = async (tableKey: string): Promise<ErDiagramTableSnapshot> => {
@@ -284,14 +333,18 @@ export const collectErDiagramNeighborhood = async (
const snapshot = await params.loadSnapshot(tableName);
const actualTableName = registerTableName(snapshot.tableName || tableName);
const normalizedActualTableName = normalizeErQualifiedName(actualTableName);
const snapshotForeignKeys = foreignKeysByKey.has(tableKey)
? foreignKeysByKey.get(tableKey) || []
: snapshot.foreignKeys || [];
const nextSnapshot = {
...snapshot,
tableName: actualTableName,
foreignKeys: snapshotForeignKeys,
};
snapshotByKey.set(tableKey, nextSnapshot);
foreignKeysByKey.set(tableKey, nextSnapshot.foreignKeys || []);
foreignKeysByKey.set(tableKey, snapshotForeignKeys);
snapshotByKey.set(normalizedActualTableName, nextSnapshot);
foreignKeysByKey.set(normalizedActualTableName, nextSnapshot.foreignKeys || []);
foreignKeysByKey.set(normalizedActualTableName, snapshotForeignKeys);
return nextSnapshot;
} catch {
warningCount += 1;
@@ -506,7 +559,9 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
const seq = ++requestSeqRef.current;
const config = normalizeConnectionConfig(connection);
const schemaCacheKey = `${cachePrefix}schemaTables`;
const databaseForeignKeysCacheKey = `${cachePrefix}databaseForeignKeys`;
const currentTableCacheKey = `${cachePrefix}${normalizedTableName}`;
const isKingbase = resolveDataSourceType(config) === 'kingbase';
setState((prev) => ({
...prev,
@@ -520,7 +575,31 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
const loadGraph = async () => {
let warningCount = 0;
const currentSnapshot = await loadTableSnapshot(config, normalizedDbName, normalizedTableName, currentTableCacheKey);
const databaseForeignKeysResultPromise = isKingbase
? loadDatabaseForeignKeys(config, normalizedDbName, databaseForeignKeysCacheKey)
.then((value) => ({ value, failed: false as const }))
.catch(() => ({ value: null, failed: true as const }))
: null;
const prefetchedCurrentForeignKeys = databaseForeignKeysResultPromise
? databaseForeignKeysResultPromise.then((result) => {
if (result.value) {
return result.value.get(normalizeErQualifiedName(normalizedTableName)) || [];
}
return loadTableForeignKeys(
config,
normalizedDbName,
normalizedTableName,
`${currentTableCacheKey}|foreignKeys`,
);
})
: undefined;
const currentSnapshot = await loadTableSnapshot(
config,
normalizedDbName,
normalizedTableName,
currentTableCacheKey,
prefetchedCurrentForeignKeys,
);
let schemaTableNames = [currentSnapshot.tableName];
try {
@@ -536,6 +615,23 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
]);
const resolveTableName = (name: string) => resolveErActualTableName(name, resolvedSchemaTableNames);
let prefetchedForeignKeysByTable: Map<string, ForeignKeyDefinition[]> | undefined;
if (databaseForeignKeysResultPromise) {
const databaseForeignKeysResult = await databaseForeignKeysResultPromise;
prefetchedForeignKeysByTable = new Map(
resolvedSchemaTableNames.map((name) => [normalizeErQualifiedName(name), []]),
);
databaseForeignKeysResult.value?.forEach((foreignKeys, tableKey) => {
prefetchedForeignKeysByTable?.set(tableKey, foreignKeys);
});
prefetchedForeignKeysByTable.set(
normalizeErQualifiedName(currentSnapshot.tableName),
currentSnapshot.foreignKeys,
);
if (databaseForeignKeysResult.failed) {
warningCount += 1;
}
}
const neighborhood = await collectErDiagramNeighborhood({
currentSnapshot,
@@ -543,13 +639,25 @@ export const useDataGridErDiagram = (params: DataGridErDiagramParams) => {
relationDepth,
loadSnapshot: async (relatedTableName) => {
const tableCacheKey = `${cachePrefix}${relatedTableName}`;
return loadTableSnapshot(config, normalizedDbName, relatedTableName, tableCacheKey);
const prefetchedForeignKeys = prefetchedForeignKeysByTable
? Promise.resolve(
prefetchedForeignKeysByTable.get(normalizeErQualifiedName(relatedTableName)) || [],
)
: undefined;
return loadTableSnapshot(
config,
normalizedDbName,
relatedTableName,
tableCacheKey,
prefetchedForeignKeys,
);
},
loadForeignKeys: async (relatedTableName) => {
const tableCacheKey = `${cachePrefix}${relatedTableName}`;
return loadTableForeignKeys(config, normalizedDbName, relatedTableName, `${tableCacheKey}|foreignKeys`);
},
resolveTableName,
prefetchedForeignKeysByTable,
});
warningCount += neighborhood.warningCount;

View File

@@ -420,6 +420,7 @@ if (
DBGetDatabases: async () => ({ success: true, data: ['missav_bot'] }),
DBGetTables: async () => ({ success: true, data: cloneBrowserMockValue(mockQueryTables) }),
DBGetAllColumns: async () => ({ success: true, data: cloneBrowserMockValue(mockQueryColumns) }),
DBGetDatabaseForeignKeys: async () => ({ success: true, data: {} }),
DBGetColumns: async (_config: any, _dbName: string, tableName: string) => ({
success: true,
data: cloneBrowserMockValue(

View File

@@ -62,6 +62,8 @@ export function DBGetColumns(arg1:connection.ConnectionConfig,arg2:string,arg3:s
export function DBGetDatabases(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function DBGetDatabaseForeignKeys(arg1:connection.ConnectionConfig,arg2:string):Promise<connection.QueryResult>;
export function DBGetForeignKeys(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DBGetIndexes(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;

View File

@@ -110,6 +110,10 @@ export function DBGetDatabases(arg1) {
return window['go']['app']['App']['DBGetDatabases'](arg1);
}
export function DBGetDatabaseForeignKeys(arg1, arg2) {
return window['go']['app']['App']['DBGetDatabaseForeignKeys'](arg1, arg2);
}
export function DBGetForeignKeys(arg1, arg2, arg3) {
return window['go']['app']['App']['DBGetForeignKeys'](arg1, arg2, arg3);
}