mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-26 08:21:50 +08:00
🐛 fix(query-editor): 修复结果集阻塞与未知总数分页误导
- 为查询结果定位元数据探测增加软超时降级,避免租户元数据卡死阻塞主查询结果渲染 - 将未知总数分页切换为顺序翻页模式,并修正文案仅在真实统计时显示正在统计中 - 补充查询结果与分页回归测试,覆盖元数据超时和 legacy 未知总数分页场景
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user