🐛 fix(data-grid): 修复字段元数据偶发缺失

- 为字段元数据提取补充可用性判断并在空类型/空备注时自动重试
- 刷新结果集时同步清理字段、外键和唯一键缓存并强制补拉元数据
- 补充 DataGrid 头部元数据回归测试,覆盖首次空结果重试与刷新重载场景
This commit is contained in:
Syngnat
2026-06-18 10:57:51 +08:00
parent 2a8ae05363
commit 293fc6e0fe
2 changed files with 169 additions and 23 deletions

View File

@@ -9,6 +9,7 @@ import DataGrid, {
GONAVI_ROW_KEY,
hasDataGridVirtualEditRenderVersionChanged,
} from './DataGrid';
import DataGridToolbarFrame from './DataGridToolbarFrame';
import { V2CellContextMenuView, V2ColumnHeaderContextMenuView, V2TableGroupContextMenuView } from './V2TableContextMenu';
import { setCurrentLanguage, t } from '../i18n';
import { DUCKDB_ROWID_LOCATOR_COLUMN, ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
@@ -96,6 +97,9 @@ vi.mock('../store', () => ({
}));
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../../wailsjs/runtime/runtime', () => ({
EventsOn: vi.fn(() => vi.fn()),
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<any>('react-dom');
@@ -291,6 +295,9 @@ vi.mock('antd', () => {
const Radio: any = ({ children }: any) => <span>{children}</span>;
Radio.Group = ({ children }: any) => <div>{children}</div>;
Radio.Button = ({ children }: any) => <button type="button">{children}</button>;
const Typography: any = ({ children }: any) => <>{children}</>;
Typography.Text = ({ children }: any) => <span>{children}</span>;
Typography.Paragraph = ({ children }: any) => <p>{children}</p>;
const Segmented = ({ value, options, onChange }: any) => (
<div data-segmented-value={value}>
{(options || []).map((option: any) => (
@@ -337,6 +344,12 @@ vi.mock('antd', () => {
Space,
Tag,
Radio,
Typography,
Progress: ({ percent, status, format }: any) => (
<div data-progress-percent={String(percent)} data-progress-status={String(status)}>
{typeof format === 'function' ? format(percent) : null}
</div>
),
};
});
@@ -348,6 +361,15 @@ const textContent = (node: any): string =>
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const renderHeaderText = (columnKey: string): string => {
const column = testRenderState.latestColumns.find((item) => item.key === columnKey);
expect(column).toBeTruthy();
const headerRenderer = create(<>{column.title}</>);
const content = textContent(headerRenderer.root);
headerRenderer.unmount();
return content;
};
const waitForEffects = async () => {
await act(async () => {
await Promise.resolve();
@@ -651,6 +673,8 @@ describe('DataGrid DDL interactions', () => {
backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
backendApp.DBShowCreateTable.mockResolvedValue({ success: true, data: 'CREATE TABLE users' });
setCurrentLanguage('zh-CN');
storeState.queryOptions.showColumnComment = false;
storeState.queryOptions.showColumnType = false;
storeState.appearance.uiVersion = 'legacy';
storeState.dataEditTransactionOptions = {
commitMode: 'manual',
@@ -733,6 +757,7 @@ describe('DataGrid DDL interactions', () => {
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
onReload={() => {}}
/>,
);
});
@@ -866,6 +891,88 @@ describe('DataGrid DDL interactions', () => {
renderer!.unmount();
});
it('retries column metadata loading when the first response has no usable type or comment', async () => {
storeState.queryOptions.showColumnComment = true;
storeState.queryOptions.showColumnType = true;
backendApp.DBGetColumns
.mockResolvedValueOnce({
success: true,
data: [{ Name: 'id', Type: '', Comment: '' }],
})
.mockResolvedValueOnce({
success: true,
data: [{ Name: 'id', Type: 'bigint', Comment: '主键 ID' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
/>,
);
});
await waitForEffects();
await waitForEffects();
expect(backendApp.DBGetColumns).toHaveBeenCalledTimes(2);
const headerText = renderHeaderText('id');
expect(headerText).toContain('bigint');
expect(headerText).toContain('主键 ID');
renderer!.unmount();
});
it('reloads column metadata after clicking refresh', async () => {
storeState.queryOptions.showColumnComment = true;
storeState.queryOptions.showColumnType = true;
backendApp.DBGetColumns
.mockResolvedValueOnce({
success: true,
data: [{ Name: 'id', Type: 'bigint', Comment: '旧备注' }],
})
.mockResolvedValueOnce({
success: true,
data: [{ Name: 'id', Type: 'varchar(64)', Comment: '新备注' }],
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
pkColumns={['id']}
/>,
);
});
await waitForEffects();
expect(backendApp.DBGetColumns).toHaveBeenCalledTimes(1);
expect(renderHeaderText('id')).toContain('旧备注');
await act(async () => {
renderer!.root.findByType(DataGridToolbarFrame).props.onRefresh();
});
await waitForEffects();
await waitForEffects();
expect(backendApp.DBGetColumns).toHaveBeenCalledTimes(2);
const headerText = renderHeaderText('id');
expect(headerText).toContain('varchar(64)');
expect(headerText).toContain('新备注');
renderer!.unmount();
});
it('localizes v2 column header fallback labels', () => {
setCurrentLanguage('en-US');

View File

@@ -1346,6 +1346,27 @@ type ColumnMeta = {
comment: string;
};
const buildColumnMetaMap = (columns: ColumnDefinition[]): Record<string, ColumnMeta> => {
const nextMap: Record<string, ColumnMeta> = {};
(columns || []).forEach((column: any) => {
const name = getColumnDefinitionName(column);
if (!name) return;
nextMap[name] = {
type: getColumnDefinitionType(column),
comment: getColumnDefinitionComment(column),
};
});
return nextMap;
};
const hasUsableColumnMeta = (metaMap: Record<string, ColumnMeta>): boolean => (
Object.values(metaMap || {}).some((meta) => {
const type = String(meta?.type || '').trim();
const comment = String(meta?.comment || '').trim();
return type.length > 0 || comment.length > 0;
})
);
type ForeignKeyTarget = {
columnName: string;
refTableName: string;
@@ -2210,6 +2231,7 @@ const DataGrid: React.FC<DataGridProps> = ({
const [columnMetaMap, setColumnMetaMap] = useState<Record<string, ColumnMeta>>({});
const [foreignKeyMap, setForeignKeyMap] = useState<Record<string, ForeignKeyTarget>>({});
const [uniqueKeyGroups, setUniqueKeyGroups] = useState<string[][]>([]);
const [metadataReloadVersion, setMetadataReloadVersion] = useState(0);
const mergedDisplayDataRef = useRef<Item[]>([]);
const closeCellEditModeRef = useRef<() => void>(() => {});
const formRef = useRef(form);
@@ -2269,29 +2291,37 @@ const DataGrid: React.FC<DataGridProps> = ({
};
const seq = ++columnMetaSeqRef.current;
DBGetColumns(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName)
.then((res) => {
if (seq !== columnMetaSeqRef.current) return;
if (!res.success || !Array.isArray(res.data)) {
setColumnMetaMap({});
return;
const loadColumnMeta = async () => {
let nextMap: Record<string, ColumnMeta> | null = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, normalizedDbName, normalizedTableName);
if (seq !== columnMetaSeqRef.current) return;
if (!res.success || !Array.isArray(res.data)) {
continue;
}
const candidateMap = buildColumnMetaMap(res.data as ColumnDefinition[]);
if (!hasUsableColumnMeta(candidateMap)) {
continue;
}
nextMap = candidateMap;
break;
} catch {
if (seq !== columnMetaSeqRef.current) return;
}
const nextMap: Record<string, ColumnMeta> = {};
(res.data as ColumnDefinition[]).forEach((column: any) => {
const name = getColumnDefinitionName(column);
if (!name) return;
const type = getColumnDefinitionType(column);
const comment = getColumnDefinitionComment(column);
nextMap[name] = { type, comment };
});
}
if (seq !== columnMetaSeqRef.current) return;
if (nextMap) {
columnMetaCacheRef.current[cacheKey] = nextMap;
setColumnMetaMap(nextMap);
})
.catch(() => {
if (seq !== columnMetaSeqRef.current) return;
setColumnMetaMap({});
});
}, [connections, connectionId, dbName, tableName]);
return;
}
setColumnMetaMap({});
};
void loadColumnMeta();
}, [connections, connectionId, dbName, tableName, metadataReloadVersion]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
@@ -2344,7 +2374,7 @@ const DataGrid: React.FC<DataGridProps> = ({
if (seq !== foreignKeySeqRef.current) return;
setForeignKeyMap({});
});
}, [connections, connectionId, dbName, tableName, exportScope]);
}, [connections, connectionId, dbName, tableName, exportScope, metadataReloadVersion]);
useEffect(() => {
const normalizedTableName = String(tableName || '').trim();
@@ -2385,7 +2415,7 @@ const DataGrid: React.FC<DataGridProps> = ({
if (seq !== uniqueKeyGroupsSeqRef.current) return;
setUniqueKeyGroups([]);
});
}, [connections, connectionId, dbName, tableName]);
}, [connections, connectionId, dbName, tableName, metadataReloadVersion]);
const columnMetaMapByLowerName = useMemo(() => {
const next: Record<string, ColumnMeta> = {};
@@ -7741,8 +7771,17 @@ const DataGrid: React.FC<DataGridProps> = ({
setDeletedRowKeys(new Set());
setModifiedColumns({});
setSelectedRowKeys([]);
const normalizedTableName = String(tableName || '').trim();
const normalizedDbName = String(dbName || '').trim();
if (connectionId && normalizedTableName) {
const cacheKey = `${connectionId}|${normalizedDbName}|${normalizedTableName}`;
delete columnMetaCacheRef.current[cacheKey];
delete foreignKeyCacheRef.current[cacheKey];
delete uniqueKeyGroupsCacheRef.current[cacheKey];
setMetadataReloadVersion((value) => value + 1);
}
if (onReload) onReload();
}, [clearAutoCommitTimer, onReload]);
}, [clearAutoCommitTimer, connectionId, dbName, onReload, tableName]);
const handleResetPendingChanges = useCallback(() => {
clearAutoCommitTimer();