🐛 fix(query-editor/data-grid): 修复查询结果预览与保存状态

- 修正保存队列、草稿同步及保存结果校验的生命周期位置
- 查询完成后通过本地请求切回表格数据预览
- 支持 SQLite、ClickHouse、MongoDB 及空结果集重复执行
- 避免共享 DDL 展示状态覆盖新查询结果
- 补充数据预览、DDL 状态恢复和保存状态回归测试
This commit is contained in:
Syngnat
2026-07-23 16:34:09 +08:00
parent ba28c626fd
commit d8c4352b66
7 changed files with 237 additions and 31 deletions

View File

@@ -4414,6 +4414,102 @@ describe('DataGrid DDL interactions', () => {
expect(content).not.toContain('CREATE TABLE users');
});
it('returns a query result to data preview when a fresh table view request arrives', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBShowCreateTable
.mockResolvedValueOnce({
success: true,
data: 'CREATE TABLE users (`id` bigint)',
})
.mockResolvedValueOnce({
success: true,
data: 'CREATE TABLE users (`id` bigint)',
})
.mockResolvedValueOnce({
success: true,
data: 'CREATE TABLE orders (`id` bigint)',
});
const renderGrid = (initialViewModeRequestId?: string, rowId = 1) => (
<DataGrid
data={[{ __gonavi_row_key__: `row-${rowId}`, id: rowId }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
initialViewMode={initialViewModeRequestId ? 'table' : undefined}
initialViewModeRequestId={initialViewModeRequestId}
initialViewModeScope={initialViewModeRequestId ? 'local' : undefined}
/>
);
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(renderGrid());
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await waitForEffects();
expect(renderer!.root.findAll((node) => node.props['data-grid-ddl-view'])).toHaveLength(1);
await act(async () => {
renderer!.update(renderGrid('query-run-1', 2));
});
await waitForEffects();
expect(renderer!.root.findAll((node) => node.props['data-grid-ddl-view'])).toHaveLength(0);
expect(testRenderState.latestTableProps.dataSource[0]).toMatchObject({ id: 2 });
expect(backendApp.DBShowCreateTable).toHaveBeenCalledTimes(1);
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await waitForEffects();
expect(renderer!.root.findAll((node) => node.props['data-grid-ddl-view'])).toHaveLength(1);
expect(backendApp.DBShowCreateTable).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ type: 'mysql' }),
'main',
'users',
);
await act(async () => {
renderer!.update(renderGrid('query-run-2', 3));
});
await waitForEffects();
expect(renderer!.root.findAll((node) => node.props['data-grid-ddl-view'])).toHaveLength(0);
expect(testRenderState.latestTableProps.dataSource[0]).toMatchObject({ id: 3 });
await act(async () => {
renderer!.update(
<DataGrid
key="orders"
data={[{ __gonavi_row_key__: 'row-4', id: 4 }]}
columnNames={['id']}
loading={false}
tableName="orders"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
expect(renderer!.root.findAll((node) => node.props['data-grid-ddl-view'])).toHaveLength(1);
expect(backendApp.DBShowCreateTable).toHaveBeenNthCalledWith(
3,
expect.objectContaining({ type: 'mysql' }),
'main',
'orders',
);
expect(textContent(renderer!.root)).toContain('CREATE TABLE orders');
expect(backendApp.DBShowCreateTable).toHaveBeenCalledTimes(3);
});
it('keeps the v2 DDL sidebar open when switching to another table tab instance', async () => {
storeState.appearance.uiVersion = 'v2';
let resolveOrdersRequest: (value: any) => void = () => {};

View File

@@ -309,6 +309,7 @@ const DataGrid: React.FC<DataGridProps> = ({
scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions, showRowNumberColumn, isActive = true, enableSqlLogEvent = false,
initialViewMode,
initialViewModeRequestId,
initialViewModeScope,
onDataViewActivate,
onDataChange,
}) => {
@@ -1749,6 +1750,7 @@ const DataGrid: React.FC<DataGridProps> = ({
translate: translateDataGrid,
initialViewMode,
initialViewModeRequestId,
initialViewModeScope,
});
useEffect(() => {

View File

@@ -1375,6 +1375,7 @@ interface DataGridProps {
enableSqlLogEvent?: boolean;
initialViewMode?: GridViewMode;
initialViewModeRequestId?: string;
initialViewModeScope?: 'shared' | 'local';
onDataViewActivate?: () => void;
onDataChange?: (rows: any[]) => void;
}

View File

@@ -2333,8 +2333,8 @@ describe('QueryEditor external SQL save', () => {
renderer.unmount();
});
it.each(['sqlite', 'clickhouse'])(
'activates the data result tab for %s after the sql log tab was open',
it.each(['sqlite', 'clickhouse', 'mongodb'])(
'activates the data result tab and requests data preview for %s after the sql log tab was open',
async (dbType) => {
storeState.appearance.uiVersion = 'v2';
storeState.connections[0].config.type = dbType;
@@ -2350,14 +2350,22 @@ describe('QueryEditor external SQL save', () => {
data: [{ name: 'id', key: 'PRI' }],
});
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{
columns: ['id', 'name'],
rows: [{ id: 1, name: 'alpha' }],
statementIndex: 1,
}],
});
if (dbType === 'mongodb') {
backendApp.DBQueryWithCancel.mockResolvedValue({
success: true,
data: [{ id: 1, name: 'alpha' }],
fields: ['id', 'name'],
});
} else {
backendApp.DBQueryMulti.mockResolvedValue({
success: true,
data: [{
columns: ['id', 'name'],
rows: [{ id: 1, name: 'alpha' }],
statementIndex: 1,
}],
});
}
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
@@ -2400,8 +2408,56 @@ describe('QueryEditor external SQL save', () => {
expect(textContent(renderer.toJSON())).toContain('结果 1');
expect(dataGridState.latestProps?.columnNames).toEqual(['id', 'name']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ id: 1, name: 'alpha' });
expect(dataGridState.latestProps?.initialViewMode).toBe('table');
expect(dataGridState.latestProps?.initialViewModeScope).toBe('local');
const firstDataPreviewRequestId = dataGridState.latestProps?.initialViewModeRequestId;
expect(firstDataPreviewRequestId).toEqual(expect.any(String));
renderer.unmount();
await act(async () => {
await findButton(renderer, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.initialViewMode).toBe('table');
expect(dataGridState.latestProps?.initialViewModeScope).toBe('local');
expect(dataGridState.latestProps?.initialViewModeRequestId).toEqual(expect.any(String));
expect(dataGridState.latestProps?.initialViewModeRequestId).not.toBe(firstDataPreviewRequestId);
const secondDataPreviewRequestId = dataGridState.latestProps?.initialViewModeRequestId;
if (dbType === 'mongodb') {
backendApp.DBQueryWithCancel.mockResolvedValueOnce({
success: true,
data: [],
fields: [],
});
} else {
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: [], rows: [], statementIndex: 1 }],
});
}
await act(async () => {
await findButton(renderer, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.columnNames).toEqual([]);
expect(dataGridState.latestProps?.data).toEqual([]);
expect(dataGridState.latestProps?.initialViewMode).toBe('table');
expect(dataGridState.latestProps?.initialViewModeScope).toBe('local');
expect(dataGridState.latestProps?.initialViewModeRequestId).toEqual(expect.any(String));
expect(dataGridState.latestProps?.initialViewModeRequestId).not.toBe(secondDataPreviewRequestId);
await act(async () => {
renderer.unmount();
});
},
);

View File

@@ -1285,6 +1285,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const [activeResultKey, setActiveResultKey] = useState<string>(
() => restoredResultSessionRef.current?.activeResultKey || '',
);
const [resultDataPreviewRequest, setResultDataPreviewRequest] = useState<{
resultKey: string;
requestId: string;
} | null>(null);
const resultSetsRef = useRef(resultSets);
const activeResultKeyRef = useRef(activeResultKey);
const nativeRestoredResultRefs = useRef(new Map<
@@ -6355,6 +6359,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
),
);
const isQueryDataGridResultSet = (result?: ResultSet | null): boolean =>
Boolean(
result &&
result.resultType !== 'message' &&
!isAffectedRowsResultSet(result),
);
const resolveActiveResultKeyAfterMerge = (merged: ResultSet[], executed: ResultSet[]): string => {
const firstExecutedResult = executed.find((result) => isConcreteGridResultSet(result))
|| executed.find((result) => isMessageLikeResultSet(result))
@@ -6371,6 +6382,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|| '';
};
const activateExecutedResult = (merged: ResultSet[], executed: ResultSet[], requestSeq: number) => {
const nextActiveResultKey = resolveActiveResultKeyAfterMerge(merged, executed);
const nextActiveResult = merged.find((result) => result.key === nextActiveResultKey);
setActiveResultKey(nextActiveResultKey);
setResultDataPreviewRequest(isQueryDataGridResultSet(nextActiveResult)
? {
resultKey: nextActiveResultKey,
requestId: `${tab.id}:${requestSeq}`,
}
: null);
};
const resolveExecutableSQLAtEditorPosition = (model: any, sqlText: string, position: any, dbType = ''): string => {
const normalizedPosition = normalizeEditorPosition(position);
if (!normalizedPosition) return '';
@@ -7085,7 +7108,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const shouldReplaceAllResults = didExecuteWholeEditor;
const mergedResultSets = mergeResultSets(resultSets, nextResultSets, shouldReplaceAllResults);
setResultSets(mergedResultSets);
setActiveResultKey(resolveActiveResultKeyAfterMerge(mergedResultSets, nextResultSets));
activateExecutedResult(mergedResultSets, nextResultSets, runSeq);
if (didExecuteAppendedSql || didExecuteWholeEditor) {
lastExecutedEditorQueryRef.current = currentQuery;
}
@@ -7496,7 +7519,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const shouldReplaceAllResults = didExecuteWholeEditor;
const mergedResultSets = mergeResultSets(resultSets, nextResultSets, shouldReplaceAllResults);
setResultSets(mergedResultSets);
setActiveResultKey(resolveActiveResultKeyAfterMerge(mergedResultSets, nextResultSets));
activateExecutedResult(mergedResultSets, nextResultSets, runSeq);
if (didExecuteAppendedSql || didExecuteWholeEditor) {
lastExecutedEditorQueryRef.current = currentQuery;
}
@@ -9079,6 +9102,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
isV2Ui={isV2Ui}
currentDb={currentDb}
currentConnectionId={currentConnectionId}
dataPreviewRequest={resultDataPreviewRequest}
toggleShortcutLabel={toggleQueryResultsPanelShortcutLabel}
onActiveResultKeyChange={setActiveResultKey}
onHide={() => updateResultPanelVisibility(false)}

View File

@@ -79,6 +79,7 @@ interface QueryEditorResultsPanelProps {
isV2Ui: boolean;
currentDb: string;
currentConnectionId: string;
dataPreviewRequest?: { resultKey: string; requestId: string } | null;
toggleShortcutLabel: string;
onActiveResultKeyChange: (key: string) => void;
onHide: () => void;
@@ -140,6 +141,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
isV2Ui,
currentDb,
currentConnectionId,
dataPreviewRequest,
toggleShortcutLabel,
onActiveResultKeyChange,
onHide,
@@ -601,6 +603,9 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
ddlDbName={rs.ddlDbName}
ddlTableName={rs.ddlTableName}
connectionId={currentConnectionId}
initialViewMode={dataPreviewRequest?.resultKey === rs.key ? 'table' : undefined}
initialViewModeRequestId={dataPreviewRequest?.resultKey === rs.key ? dataPreviewRequest.requestId : undefined}
initialViewModeScope={dataPreviewRequest?.resultKey === rs.key ? 'local' : undefined}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
onReload={() => {

View File

@@ -105,6 +105,7 @@ interface UseDataGridDdlViewParams {
translate?: (key: string, params?: TranslateParams) => string;
initialViewMode?: GridViewMode;
initialViewModeRequestId?: string;
initialViewModeScope?: 'shared' | 'local';
}
export interface UseDataGridDdlViewResult {
@@ -145,10 +146,19 @@ export const useDataGridDdlView = ({
translate,
initialViewMode,
initialViewModeRequestId,
initialViewModeScope,
}: UseDataGridDdlViewParams): UseDataGridDdlViewResult => {
const canRestoreSharedDdlView = isV2Ui && canViewDdl && !!currentConnConfig && !!tableName && shouldRestoreSharedDdlView();
const shouldStartWithSharedDdlView = isActive && canRestoreSharedDdlView;
const initialResolvedViewMode = resolveInitialGridViewMode(initialViewMode, isV2Ui, canViewDdl);
const shouldInitiallySuppressSharedDdlView = initialViewModeScope === 'local'
&& initialResolvedViewMode === 'table';
const [suppressSharedDdlView, setSuppressSharedDdlView] = React.useState(shouldInitiallySuppressSharedDdlView);
const canRestoreSharedDdlView = isV2Ui
&& canViewDdl
&& !!currentConnConfig
&& !!tableName
&& !suppressSharedDdlView
&& shouldRestoreSharedDdlView();
const shouldStartWithSharedDdlView = isActive && canRestoreSharedDdlView;
const [viewMode, setViewMode] = React.useState<GridViewMode>(() => (
shouldStartWithSharedDdlView ? 'ddl' : (initialResolvedViewMode || 'table')
));
@@ -174,13 +184,21 @@ export const useDataGridDdlView = ({
() => buildDdlContextKey(currentConnConfig, dbName, tableName),
[currentConnConfig, dbName, tableName],
);
const appliedInitialViewModeKeyRef = React.useRef('');
const initialViewModeApplyKey = `${ddlContextKey}\u0001${initialResolvedViewMode || ''}\u0001${initialViewModeRequestId || ''}\u0001${initialViewModeScope || 'shared'}`;
const hasPendingLocalTableViewRequest = isActive
&& initialResolvedViewMode === 'table'
&& initialViewModeScope === 'local'
&& appliedInitialViewModeKeyRef.current !== initialViewModeApplyKey;
const ddlViewLayout = ddlViewLayoutState;
const resolvedViewMode: GridViewMode = isActive
&& canRestoreSharedDdlView
&& (viewMode === 'table' || viewMode === 'ddl')
? 'ddl'
: viewMode;
const resolvedViewMode: GridViewMode = hasPendingLocalTableViewRequest
? 'table'
: isActive
&& canRestoreSharedDdlView
&& (viewMode === 'table' || viewMode === 'ddl')
? 'ddl'
: viewMode;
const isDdlContextPending = resolvedViewMode === 'ddl'
&& isActive
&& canRestoreSharedDdlView
@@ -223,6 +241,7 @@ export const useDataGridDdlView = ({
const requestSeq = ++ddlRequestSeqRef.current;
ddlRequestedContextKeyRef.current = ddlContextKey;
if (asView) {
setSuppressSharedDdlView(false);
setSharedDdlViewOpen(true);
setViewMode('ddl');
setDdlModalOpen(false);
@@ -255,6 +274,7 @@ export const useDataGridDdlView = ({
}, [isV2Ui, viewMode]);
const closeDdlView = React.useCallback(() => {
setSuppressSharedDdlView(false);
setSharedDdlViewOpen(false);
ddlRequestedContextKeyRef.current = null;
ddlRequestSeqRef.current += 1;
@@ -266,13 +286,13 @@ export const useDataGridDdlView = ({
}, []);
React.useEffect(() => {
if (!isActive || !isV2Ui || !shouldRestoreSharedDdlView()) return;
if (!canViewDdl || !currentConnConfig || !tableName) return;
if (!isActive || hasPendingLocalTableViewRequest || !canRestoreSharedDdlView) return;
if (ddlRequestedContextKeyRef.current === ddlContextKey) return;
void handleOpenTableDdl({ asView: true });
}, [canViewDdl, currentConnConfig, ddlContextKey, handleOpenTableDdl, isActive, isV2Ui, tableName]);
}, [canRestoreSharedDdlView, ddlContextKey, handleOpenTableDdl, hasPendingLocalTableViewRequest, isActive]);
const handleViewModeChange = React.useCallback((nextMode: GridViewMode) => {
setSuppressSharedDdlView(false);
if ((nextMode === 'fields' || nextMode === 'ddl' || nextMode === 'er' || nextMode === 'sqlLog') && !isV2Ui) {
setSharedDdlViewOpen(false);
setViewMode('table');
@@ -309,16 +329,18 @@ export const useDataGridDdlView = ({
setViewMode(nextMode);
}, [cellEditMode, closeCellEditModeRef, closeDdlView, handleOpenTableDdl, isV2Ui, mergedDisplayDataRef, resolvedViewMode, rowKeyStr, selectedRowKeys, setTextRecordIndex]);
const appliedInitialViewModeKeyRef = React.useRef('');
React.useEffect(() => {
if (!isActive) return;
const nextInitialViewMode = resolveInitialGridViewMode(initialViewMode, isV2Ui, canViewDdl);
if (!nextInitialViewMode) return;
const applyKey = `${ddlContextKey}\u0001${nextInitialViewMode}\u0001${initialViewModeRequestId || ''}`;
if (appliedInitialViewModeKeyRef.current === applyKey) return;
appliedInitialViewModeKeyRef.current = applyKey;
handleViewModeChange(nextInitialViewMode);
}, [canViewDdl, ddlContextKey, handleViewModeChange, initialViewMode, initialViewModeRequestId, isActive, isV2Ui]);
if (!initialResolvedViewMode) return;
if (appliedInitialViewModeKeyRef.current === initialViewModeApplyKey) return;
appliedInitialViewModeKeyRef.current = initialViewModeApplyKey;
if (initialResolvedViewMode === 'table' && initialViewModeScope === 'local') {
setSuppressSharedDdlView(true);
setViewMode('table');
return;
}
handleViewModeChange(initialResolvedViewMode);
}, [handleViewModeChange, initialResolvedViewMode, initialViewModeApplyKey, initialViewModeScope, isActive]);
const handleDdlSidebarResizeStart = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();