🐛 fix(query-editor): 修复结果集阻塞与未知总数分页误导

- 为查询结果定位元数据探测增加软超时降级,避免租户元数据卡死阻塞主查询结果渲染
- 将未知总数分页切换为顺序翻页模式,并修正文案仅在真实统计时显示正在统计中
- 补充查询结果与分页回归测试,覆盖元数据超时和 legacy 未知总数分页场景
This commit is contained in:
tianqijiuyun-latiao
2026-06-24 10:47:37 +08:00
parent 1aab48783d
commit 7dab6f2e33
8 changed files with 202 additions and 38 deletions

View File

@@ -1902,6 +1902,45 @@ describe('DataGrid layout', () => {
expect(markup).not.toContain('data-grid-pagination-jump="true"');
});
it('keeps legacy unknown-total pagination in sequential mode instead of numbered pages', () => {
const previousUiVersion = mockStoreState.uiVersion;
mockStoreState.uiVersion = 'legacy';
try {
const markup = renderDataGridWithI18n(
<DataGrid
data={[
{
__gonavi_row_key__: 'row-1',
id: 1,
name: 'alpha',
},
]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
readOnly
pagination={{
current: 3,
pageSize: 100,
total: 400,
totalKnown: false,
}}
onPageChange={() => {}}
/>,
);
expect(markup).toContain('第 3 页');
expect(markup).toContain('data-grid-pagination-sequential="true"');
expect(markup).not.toContain('class="ant-pagination');
expect(markup).not.toContain('data-grid-pagination-jump="true"');
} finally {
mockStoreState.uiVersion = previousUiVersion;
}
});
it('renders the v2 DataGrid toolbar using the redesigned topbar hooks', () => {
const markup = renderDataGridWithI18n(
<DataGrid

View File

@@ -48,6 +48,7 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
translate = defaultTranslate,
}) => {
const [jumpPage, setJumpPage] = React.useState<number | null>(pagination?.current ?? null);
const showSequentialPagination = !showKnownPageCount;
React.useEffect(() => {
setJumpPage(pagination?.current ?? null);
@@ -93,6 +94,31 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
</Button>
</div>
) : null;
const sequentialPaginationControl = (
<div
className="data-grid-pagination-sequential"
data-grid-pagination-sequential="true"
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
<Button
data-grid-pagination-prev="true"
size="small"
icon={<LeftOutlined />}
disabled={!onPageChange || pagination.current <= 1}
onClick={() => onV2PageStep('previous')}
/>
<div className="data-grid-pagination-page-chip" data-grid-page-chip="true">
<span>{paginationPageText}</span>
</div>
<Button
data-grid-pagination-next="true"
size="small"
icon={<RightOutlined />}
disabled={!onPageChange || pagination.current >= paginationTotalPages}
onClick={() => onV2PageStep('next')}
/>
</div>
);
return (
<div
@@ -146,24 +172,26 @@ const DataGridPaginationBar: React.FC<DataGridPaginationBarProps> = ({
<span className="data-grid-pagination-kicker">{translate('data_grid.pagination.result_set')}</span>
<span className="data-grid-pagination-summary-value">{paginationSummaryText}</span>
</div>
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={paginationControlTotal}
showSizeChanger={false}
onChange={onPageChange}
showTitle={false}
size="small"
itemRender={(_page, type, originalElement) => {
if (type === 'prev') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
}
if (type === 'next') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
}
return originalElement;
}}
/>
{showSequentialPagination ? sequentialPaginationControl : (
<Pagination
current={pagination.current}
pageSize={pagination.pageSize}
total={paginationControlTotal}
showSizeChanger={false}
onChange={onPageChange}
showTitle={false}
size="small"
itemRender={(_page, type, originalElement) => {
if (type === 'prev') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><LeftOutlined /></span>;
}
if (type === 'next') {
return <span className="data-grid-pagination-nav-icon" aria-hidden="true"><RightOutlined /></span>;
}
return originalElement;
}}
/>
)}
{jumpPageControl}
<Select
size="small"

View File

@@ -369,6 +369,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
const duckdbApproxKeyRef = useRef<string>('');
const oracleApproxSeqRef = useRef(0);
const oracleApproxKeyRef = useRef<string>('');
const autoCountKeyRef = useRef<string>('');
const manualCountSeqRef = useRef(0);
const manualCountKeyRef = useRef<string>('');
const pkSeqRef = useRef(0);
@@ -463,6 +464,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
countKeyRef.current = '';
duckdbApproxKeyRef.current = '';
oracleApproxKeyRef.current = '';
autoCountKeyRef.current = '';
manualCountKeyRef.current = '';
duckdbSafeSelectCacheRef.current = {};
latestConfigRef.current = null;
@@ -923,7 +925,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
return { ...prev, current: currentPage, pageSize: size };
}
}
const keepManualCounting = prev.totalCountLoading && manualCountKeyRef.current === countKey;
const keepTotalCounting = prev.totalCountLoading && (
manualCountKeyRef.current === countKey || autoCountKeyRef.current === countKey
);
const hasApproximateTotalForCurrentKey =
prev.totalApprox &&
(duckdbApproxKeyRef.current === countKey || oracleApproxKeyRef.current === countKey) &&
@@ -938,7 +942,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
totalKnown: false,
totalApprox: true,
approximateTotal: prev.approximateTotal,
totalCountLoading: keepManualCounting,
totalCountLoading: keepTotalCounting,
totalCountCancelled: false,
};
}
@@ -950,8 +954,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
totalKnown: false,
totalApprox: false,
approximateTotal: undefined,
totalCountLoading: keepManualCounting,
totalCountCancelled: keepManualCounting ? false : prev.totalCountCancelled,
totalCountLoading: keepTotalCounting,
totalCountCancelled: keepTotalCounting ? false : prev.totalCountCancelled,
};
});
@@ -959,11 +963,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (shouldRunAsyncCount) {
if (countKeyRef.current !== countKey) {
countKeyRef.current = countKey;
autoCountKeyRef.current = countKey;
const countSeq = ++countSeqRef.current;
const countStart = Date.now();
// Large-table COUNT(*) can be slow and may delay later operations in some runtimes.
// DuckDB large-file scenarios disable background COUNT because it can slow pagination significantly.
const countConfig = buildRpcConnectionConfig(config, { timeout: 5 });
setPagination(prev => ({ ...prev, totalCountLoading: true, totalCountCancelled: false }));
DBQuery(countConfig, dbName, countSql)
.then((resCount: any) => {
@@ -982,11 +988,21 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
if (countSeqRef.current !== countSeq) return;
if (latestCountKeyRef.current !== countKey) return;
if (!resCount.success) return;
if (!Array.isArray(resCount.data) || resCount.data.length === 0) return;
autoCountKeyRef.current = '';
if (!resCount.success) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
return;
}
if (!Array.isArray(resCount.data) || resCount.data.length === 0) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
return;
}
const total = parseTotalFromCountRow(resCount.data[0]);
if (total === null) return;
if (total === null) {
setPagination(prev => ({ ...prev, totalCountLoading: false }));
return;
}
setPagination(prev => ({
...prev,
@@ -1001,6 +1017,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
.catch(() => {
if (countSeqRef.current !== countSeq) return;
if (countKeyRef.current !== countKey) return;
autoCountKeyRef.current = '';
setPagination(prev => ({ ...prev, totalCountLoading: false }));
// Count failures do not block the main flow; details stay in the SQL log.
});
}

View File

@@ -5716,7 +5716,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 2');
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(queryResultMessageText(renderer!)).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
@@ -5748,7 +5748,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 2');
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(queryResultMessageText(renderer!)).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();

View File

@@ -1306,6 +1306,49 @@ describe('QueryEditor external SQL save', () => {
);
});
it('falls back to read-only results when query locator metadata stalls', async () => {
vi.useFakeTimers();
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME'], rows: [{ NAME: 'alpha' }] }],
});
backendApp.DBGetColumns.mockReturnValueOnce(new Promise(() => {}));
backendApp.DBGetIndexes.mockReturnValueOnce(new Promise(() => {}));
try {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'main', query: 'SELECT NAME FROM users' })} />);
});
await act(async () => {
findButton(renderer!, '运行').props.onClick();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(2000);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(
expect.anything(),
'main',
'SELECT NAME FROM users LIMIT 5000',
'query-1',
);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ NAME: 'alpha' });
expect(dataGridState.latestProps?.tableName).toBe('users');
expect(dataGridState.latestProps?.readOnly).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('keeps MySQL information_schema routine results read-only without a locator warning', async () => {
const sql = [
'SELECT ROUTINE_SCHEMA, ROUTINE_NAME, DEFINER, SECURITY_TYPE',

View File

@@ -25,8 +25,30 @@ export type CompletionTriggerMeta = {dbName: string, triggerName: string, tableN
export type CompletionRoutineMeta = {dbName: string, routineName: string, routineType: string, schemaName?: string};
export const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_';
const QUERY_LOCATOR_METADATA_TIMEOUT_MS = 1500;
const SQLSERVER_MESSAGE_PREFIX_RE = /^\s*mssql:/i;
const withSoftTimeout = <T,>(promise: Promise<T>, fallback: () => T, timeoutMs = QUERY_LOCATOR_METADATA_TIMEOUT_MS): Promise<T> => {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || typeof globalThis.setTimeout !== 'function') {
return promise.catch(() => fallback());
}
return new Promise<T>((resolve) => {
let settled = false;
const finish = (value: T) => {
if (settled) return;
settled = true;
globalThis.clearTimeout(timerId);
resolve(value);
};
const timerId = globalThis.setTimeout(() => {
finish(fallback());
}, timeoutMs);
promise
.then((value) => finish(value))
.catch(() => finish(fallback()));
});
};
const trimBoundaryBlankEntries = (entries: string[]): string[] => {
let start = 0;
let end = entries.length;
@@ -2059,9 +2081,15 @@ export const resolveQueryLocatorPlan = async ({
try {
const [resCols, resIndexes] = await Promise.all([
DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName),
DBGetIndexes(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
withSoftTimeout(
DBGetColumns(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName),
() => ({ success: false, message: 'Timed out while loading columns', data: [] }),
),
withSoftTimeout(
DBGetIndexes(buildRpcConnectionConfig(config) as any, tableRef.metadataDbName, tableRef.metadataTableName)
.catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })),
() => ({ success: false, message: 'Timed out while loading indexes', data: [] }),
),
]);
if (!resCols?.success || !Array.isArray(resCols.data)) {
const reason = translate('query_editor.message.read_only_table_locator_metadata_unavailable', {

View File

@@ -47,6 +47,13 @@ describe('dataGridPagination', () => {
translate: keyEchoTranslate,
})).toBe('data_grid.pagination.summary.counting {"current":1}');
expect(resolvePaginationSummaryText({
pagination: { ...pagination, totalKnown: false },
prefersManualTotalCount: false,
supportsApproximateTableCount: false,
translate: keyEchoTranslate,
})).toBe('data_grid.pagination.summary.not_counted {"current":1}');
expect(resolvePaginationSummaryText({
pagination: {
...pagination,

View File

@@ -46,15 +46,16 @@ export const resolvePaginationSummaryText = (params: {
const approximateTotal = resolveApproximateTotal(pagination);
if (pagination.totalKnown === false) {
if (prefersManualTotalCount) {
if (pagination.totalCountLoading) return translate('data_grid.pagination.summary.counting_exact', { current: currentCount });
if (supportsApproximateTableCount && approximateTotal !== null) {
return translate('data_grid.pagination.summary.approximate', { current: currentCount, total: approximateTotal });
}
if (pagination.totalCountCancelled) return translate('data_grid.pagination.summary.cancelled', { current: currentCount });
return translate('data_grid.pagination.summary.not_counted', { current: currentCount });
if (pagination.totalCountLoading) {
return prefersManualTotalCount
? translate('data_grid.pagination.summary.counting_exact', { current: currentCount })
: translate('data_grid.pagination.summary.counting', { current: currentCount });
}
return translate('data_grid.pagination.summary.counting', { current: currentCount });
if (supportsApproximateTableCount && approximateTotal !== null) {
return translate('data_grid.pagination.summary.approximate', { current: currentCount, total: approximateTotal });
}
if (pagination.totalCountCancelled) return translate('data_grid.pagination.summary.cancelled', { current: currentCount });
return translate('data_grid.pagination.summary.not_counted', { current: currentCount });
}
if (!Number.isFinite(total) || total <= 0) {