diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 73591ec4..6b11a13b 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -311,7 +311,7 @@ const DataGrid: React.FC = ({ data, columnNames, loading, tableName, columnPinScope, objectType = 'table', exportScope = 'table', dbName, ddlDbName, ddlTableName, connectionId, pkColumns = [], editLocator, readOnly = false, resultSql, resultExportAllSql, - onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, + onReload, onSort, onPageChange, onLastPage, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions, showRowNumberColumn, isActive = true, enableSqlLogEvent = false, initialViewMode, @@ -5499,6 +5499,7 @@ const DataGrid: React.FC = ({ onCancelTotalCount, onOpenErTable: openTableByName, onPageChange, + onLastPage, onReload, onRequestTotalCount, onSort, diff --git a/frontend/src/components/DataGridCore.tsx b/frontend/src/components/DataGridCore.tsx index 9c10623f..c3d414bf 100644 --- a/frontend/src/components/DataGridCore.tsx +++ b/frontend/src/components/DataGridCore.tsx @@ -1347,6 +1347,7 @@ interface DataGridProps { onReload?: () => void; onSort?: (field: string, order: string) => void; onPageChange?: (page: number, size: number) => void; + onLastPage?: (pageSize: number) => void; pagination?: { current: number, pageSize: number, diff --git a/frontend/src/components/DataGridPaginationBar.test.tsx b/frontend/src/components/DataGridPaginationBar.test.tsx index 680b9f4a..2c3b57ae 100644 --- a/frontend/src/components/DataGridPaginationBar.test.tsx +++ b/frontend/src/components/DataGridPaginationBar.test.tsx @@ -2,7 +2,10 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; -import DataGridPaginationBar, { resolveDataGridPaginationBoundaryTarget } from './DataGridPaginationBar'; +import DataGridPaginationBar, { + createDataGridLastPageAction, + resolveDataGridPaginationBoundaryTarget, +} from './DataGridPaginationBar'; describe('DataGridPaginationBar boundary navigation', () => { it('resolves the first and last page when the total page count is known', () => { @@ -102,4 +105,58 @@ describe('DataGridPaginationBar boundary navigation', () => { expect(markup).not.toContain('data-grid-pagination-total-count="true"'); }); + it('keeps the last-page action available for a fresh tail lookup at the cached boundary', () => { + const onPageChange = vi.fn(); + const onLastPage = vi.fn(); + const action = createDataGridLastPageAction({ + current: 10, + pageSize: 10, + totalPages: 10, + totalKnown: true, + onPageChange, + onLastPage, + }); + const markup = renderToStaticMarkup( + , + ); + const lastPageButton = markup.match(/]*data-grid-pagination-last="true"[^>]*>/)?.[0]; + + expect(lastPageButton).toBeDefined(); + expect(lastPageButton).not.toContain('disabled'); + expect(action).toEqual(expect.any(Function)); + action?.(); + + expect(onLastPage).toHaveBeenCalledWith(10); + expect(onPageChange).not.toHaveBeenCalled(); + }); + + it('falls back to cached last-page navigation when no fresh callback is available', () => { + const onPageChange = vi.fn(); + const action = createDataGridLastPageAction({ + current: 3, + pageSize: 10, + totalPages: 10, + totalKnown: true, + onPageChange, + }); + + expect(action).toEqual(expect.any(Function)); + action?.(); + expect(onPageChange).toHaveBeenCalledWith(10, 10); + }); + }); diff --git a/frontend/src/components/DataGridPaginationBar.tsx b/frontend/src/components/DataGridPaginationBar.tsx index 600b1bad..9001ad1e 100644 --- a/frontend/src/components/DataGridPaginationBar.tsx +++ b/frontend/src/components/DataGridPaginationBar.tsx @@ -36,6 +36,7 @@ export interface DataGridPaginationBarProps { manualTotalCountAvailable?: boolean; totalCountLoading?: boolean; onPageChange?: (page: number, size: number) => void; + onLastPage?: (pageSize: number) => void; onPageSizeChange: (value: string) => void; onV2PageStep: (direction: 'previous' | 'next') => void; onToggleTotalCount?: () => void; @@ -65,6 +66,36 @@ export const resolveDataGridPaginationBoundaryTarget = ({ return current < lastPage ? lastPage : null; }; +export const createDataGridLastPageAction = ({ + current, + pageSize, + totalPages, + totalKnown, + onPageChange, + onLastPage, +}: { + current: number; + pageSize: number; + totalPages: number; + totalKnown: boolean; + onPageChange?: (page: number, size: number) => void; + onLastPage?: (pageSize: number) => void; +}): (() => void) | null => { + if (onLastPage) { + return () => onLastPage(pageSize); + } + + const target = resolveDataGridPaginationBoundaryTarget({ + boundary: 'last', + current, + totalPages, + totalKnown, + canNavigate: Boolean(onPageChange), + }); + if (!onPageChange || target === null) return null; + return () => onPageChange(target, pageSize); +}; + const DataGridPaginationBar: React.FC = ({ isV2Ui, pagination, @@ -78,6 +109,7 @@ const DataGridPaginationBar: React.FC = ({ manualTotalCountAvailable = false, totalCountLoading = false, onPageChange, + onLastPage, onPageSizeChange, onV2PageStep, onToggleTotalCount, @@ -160,12 +192,13 @@ const DataGridPaginationBar: React.FC = ({ totalKnown: showKnownPageCount, canNavigate: Boolean(onPageChange), }); - const lastPageTarget = resolveDataGridPaginationBoundaryTarget({ - boundary: 'last', + const lastPageAction = createDataGridLastPageAction({ current: pagination.current, + pageSize: pagination.pageSize, totalPages: paginationTotalPages, totalKnown: showKnownPageCount, - canNavigate: Boolean(onPageChange), + onPageChange, + onLastPage, }); const navigateToBoundary = (target: number | null) => { if (!onPageChange || target === null) return; @@ -198,8 +231,8 @@ const DataGridPaginationBar: React.FC = ({ icon={} iconPosition="end" aria-label={lastPageLabel} - disabled={lastPageTarget === null} - onClick={() => navigateToBoundary(lastPageTarget)} + disabled={lastPageAction === null} + onClick={() => lastPageAction?.()} > {lastPageLabel} diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index 97d98044..811b38be 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -228,6 +228,7 @@ const DataGridShell: React.FC = (props) => { onCancelTotalCount, onOpenErTable, onPageChange, + onLastPage, onReload, onRequestTotalCount, onSort, @@ -490,6 +491,7 @@ const renderDataTableView = () => ( manualTotalCountAvailable={prefersManualTotalCount && !!onRequestTotalCount} totalCountLoading={pagination?.totalCountLoading} onPageChange={onPageChange} + onLastPage={onLastPage} onPageSizeChange={handlePageSizeChange} onV2PageStep={handleV2PageStep} onToggleTotalCount={onRequestTotalCount ? handleToggleTotalCount : undefined} diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index c9e1db07..1dc78499 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -673,6 +673,203 @@ describe('DataViewer safe editing locator', () => { renderer!.unmount(); }); + it.each([ + { label: 'shrinks', nextTotal: 51, expectedPage: 6, expectedOffset: 50 }, + { label: 'grows', nextTotal: 151, expectedPage: 16, expectedOffset: 150 }, + ])('recounts and navigates to the current last page when table data $label', async ({ + nextTotal, + expectedPage, + expectedOffset, + }) => { + storeState.connections[0].config.type = 'mysql'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }], + }); + + let databaseTotal = 101; + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + const normalizedSql = String(sql || ''); + if (/count\s*\(/i.test(normalizedSql)) { + return { + success: true, + fields: ['total'], + data: [{ total: databaseTotal }], + }; + } + const limit = Number(normalizedSql.match(/\bLIMIT\s+(\d+)/i)?.[1] || 101); + const offset = Number(normalizedSql.match(/\bOFFSET\s+(\d+)/i)?.[1] || 0); + const rowCount = Math.max(0, Math.min(limit, databaseTotal - offset)); + return { + success: true, + fields: ['ID', 'NAME'], + data: createRows(rowCount), + }; + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + expect(dataGridState.latestProps?.pagination).toMatchObject({ total: 101, totalKnown: true }); + + await act(async () => { + await dataGridState.latestProps.onPageChange(11, 10); + }); + await flushPromises(); + expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 11, pageSize: 10, total: 101 }); + + databaseTotal = nextTotal; + const callsBeforeLastPage = backendApp.DBQuery.mock.calls.length; + expect(dataGridState.latestProps?.onLastPage).toEqual(expect.any(Function)); + await act(async () => { + await dataGridState.latestProps.onLastPage(10); + }); + await flushPromises(); + + const lastPageSql = backendApp.DBQuery.mock.calls + .slice(callsBeforeLastPage) + .map((call: any[]) => String(call[2] || '')); + expect(lastPageSql[0]).toMatch(/count\s*\(/i); + expect(lastPageSql.some((sql: string) => new RegExp(`\\bOFFSET\\s+${expectedOffset}\\b`, 'i').test(sql))).toBe(true); + expect(dataGridState.latestProps?.pagination).toMatchObject({ + current: expectedPage, + pageSize: 10, + total: nextTotal, + totalKnown: true, + }); + expect(dataGridState.latestProps?.data).toHaveLength(1); + renderer!.unmount(); + }); + + it('ignores a stale last-page count after a newer page navigation starts', async () => { + storeState.connections[0].config.type = 'mysql'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }], + }); + + let deferNextCount = false; + let resolveDeferredCount: ((value: any) => void) | undefined; + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + const normalizedSql = String(sql || ''); + if (/count\s*\(/i.test(normalizedSql)) { + if (deferNextCount) { + deferNextCount = false; + return new Promise((resolve) => { + resolveDeferredCount = resolve; + }); + } + return { success: true, fields: ['total'], data: [{ total: 101 }] }; + } + const limit = Number(normalizedSql.match(/\bLIMIT\s+(\d+)/i)?.[1] || 101); + const offset = Number(normalizedSql.match(/\bOFFSET\s+(\d+)/i)?.[1] || 0); + return { + success: true, + fields: ['ID', 'NAME'], + data: createRows(Math.max(0, Math.min(limit, 101 - offset))), + }; + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + deferNextCount = true; + let staleLastPageRequest!: Promise; + act(() => { + staleLastPageRequest = dataGridState.latestProps.onLastPage(10); + }); + await flushPromises(); + expect(resolveDeferredCount).toEqual(expect.any(Function)); + + await act(async () => { + await dataGridState.latestProps.onPageChange(2, 10); + }); + await flushPromises(); + + await act(async () => { + resolveDeferredCount?.({ success: true, fields: ['total'], data: [{ total: 151 }] }); + await staleLastPageRequest; + }); + await flushPromises(); + + expect(dataGridState.latestProps?.pagination).toMatchObject({ + current: 2, + pageSize: 10, + total: 101, + totalKnown: true, + }); + expect(backendApp.DBQuery.mock.calls + .map((call: any[]) => String(call[2] || '')) + .some((sql: string) => /\bOFFSET\s+150\b/i.test(sql))).toBe(false); + renderer!.unmount(); + }); + + it('reports a fresh last-page count failure without querying the cached tail', async () => { + storeState.connections[0].config.type = 'mysql'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'ID', key: 'PRI' }, { name: 'NAME', key: '' }], + }); + + let failNextCount = false; + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + const normalizedSql = String(sql || ''); + if (/count\s*\(/i.test(normalizedSql)) { + if (failNextCount) { + failNextCount = false; + return { success: false, message: '', data: [] }; + } + return { success: true, fields: ['total'], data: [{ total: 101 }] }; + } + return { success: true, fields: ['ID', 'NAME'], data: createRows(101) }; + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushPromises(); + + failNextCount = true; + const callsBeforeLastPage = backendApp.DBQuery.mock.calls.length; + await act(async () => { + await dataGridState.latestProps.onLastPage(10); + }); + await flushPromises(); + + const lastPageSql = backendApp.DBQuery.mock.calls + .slice(callsBeforeLastPage) + .map((call: any[]) => String(call[2] || '')); + expect(lastPageSql).toHaveLength(1); + expect(lastPageSql[0]).toMatch(/count\s*\(/i); + expect(messageApi.error).toHaveBeenCalledWith('统计总数失败'); + renderer!.unmount(); + }); + it('shows an actionable message for DuckDB timeout interruption errors', async () => { storeState.languagePreference = 'en-US'; storeState.connections[0].config.type = 'duckdb'; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index ba3b992a..74dea5fb 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -42,6 +42,11 @@ type ViewerPaginationState = { totalCountCancelled: boolean; }; +type DataViewerFetchOptions = { + refreshTotal?: boolean; + navigateToLastPage?: boolean; +}; + type DataViewerTranslator = (key: string, params?: I18nParams) => string; const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); @@ -583,8 +588,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ setPagination(prev => ({ ...prev, totalCountLoading: false, totalCountCancelled: true })); }, []); - const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize, options?: { refreshTotal?: boolean }) => { - const refreshTotal = options?.refreshTotal === true; + const fetchData = useCallback(async (page = pagination.current, size = pagination.pageSize, options?: DataViewerFetchOptions) => { + const navigateToLastPage = options?.navigateToLastPage === true; + const refreshTotal = options?.refreshTotal === true || navigateToLastPage; const seq = ++fetchSeqRef.current; setLoading(true); const conn = connections.find(c => c.id === tab.connectionId); @@ -726,12 +732,81 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const countSql = isMongoDB ? buildMongoCountCommand(tableName, mongoFilter || {}) : `SELECT COUNT(*) as total FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; + const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`; + let refreshedTotal: number | null = null; + + if (navigateToLastPage) { + countSeqRef.current++; + manualCountSeqRef.current++; + duckdbApproxSeqRef.current++; + oracleApproxSeqRef.current++; + countKeyRef.current = ''; + autoCountKeyRef.current = ''; + manualCountKeyRef.current = ''; + duckdbApproxKeyRef.current = ''; + oracleApproxKeyRef.current = ''; + setPagination(prev => ({ + ...prev, + totalCountLoading: false, + totalCountCancelled: false, + })); + + latestConfigRef.current = config; + latestDbTypeRef.current = dbTypeLower; + latestDbNameRef.current = dbName; + latestCountSqlRef.current = countSql; + latestCountKeyRef.current = countKey; + + const countStart = Date.now(); + const countConfig = buildRpcConnectionConfig(config, { timeout: 120 }); + try { + const resCount = await DBQuery(countConfig as any, dbName, countSql); + addSqlLog({ + id: `log-${Date.now()}-last-page-count`, + timestamp: Date.now(), + sql: countSql, + status: resCount?.success ? 'success' : 'error', + duration: Date.now() - countStart, + message: resCount?.success ? '' : String(resCount?.message || tr('data_viewer.message.total_count_failed')), + dbName, + }); + + if (fetchSeqRef.current !== seq) return; + if (!resCount?.success) { + setPagination(prev => ({ ...prev, totalCountLoading: false })); + message.error(String(resCount?.message || tr('data_viewer.message.total_count_failed'))); + setLoading(false); + return; + } + if (!Array.isArray(resCount.data) || resCount.data.length === 0) { + setPagination(prev => ({ ...prev, totalCountLoading: false })); + message.error(tr('data_viewer.message.total_count_failed')); + setLoading(false); + return; + } + + refreshedTotal = parseTotalFromCountRow(resCount.data[0]); + if (refreshedTotal === null) { + setPagination(prev => ({ ...prev, totalCountLoading: false })); + message.error(tr('data_viewer.message.total_count_parse_failed')); + setLoading(false); + return; + } + } catch (e: any) { + if (fetchSeqRef.current !== seq) return; + setPagination(prev => ({ ...prev, totalCountLoading: false })); + message.error(tr('data_viewer.message.total_count_failed_detail', { detail: String(e?.message || e) })); + setLoading(false); + return; + } + } + const orderBySQL = isMongoDB ? '' : buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)); - const totalRows = Number(pagination.total); + const totalRows = refreshedTotal ?? Number(pagination.total); const hasFiniteTotal = Number.isFinite(totalRows) && totalRows >= 0; - const totalKnown = !refreshTotal && pagination.totalKnown && hasFiniteTotal; + const totalKnown = refreshedTotal !== null || (!refreshTotal && pagination.totalKnown && hasFiniteTotal); const approximateTotalRows = Number(pagination.approximateTotal); const hasApproximateTotalPages = !refreshTotal && @@ -740,9 +815,10 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ pagination.totalApprox && Number.isFinite(approximateTotalRows) && approximateTotalRows > 0; - const effectiveTotalRows = hasApproximateTotalPages ? approximateTotalRows : (refreshTotal ? 0 : totalRows); + const effectiveTotalRows = refreshedTotal ?? (hasApproximateTotalPages ? approximateTotalRows : (refreshTotal ? 0 : totalRows)); const totalPages = Number.isFinite(effectiveTotalRows) && effectiveTotalRows > 0 ? Math.max(1, Math.ceil(effectiveTotalRows / size)) : 0; - const currentPage = totalPages > 0 ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page); + const requestedPage = navigateToLastPage ? Math.max(1, totalPages) : page; + const currentPage = totalPages > 0 ? Math.min(Math.max(1, requestedPage), totalPages) : Math.max(1, requestedPage); const offset = (currentPage - 1) * size; const isClickHouse = !isMongoDB && dbTypeLower === 'clickhouse'; const reverseOrderSQL = isClickHouse ? reverseOrderBySQL(orderBySQL) : ''; @@ -905,7 +981,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = `row-${offset + i}`; }); setData(resultData); - const countKey = `${tab.connectionId}|${dbName}|${tableName}|${whereSQL}`; const derivedTotalKnown = !hasMore; const derivedTotal = derivedTotalKnown ? offset + resultData.length : currentPage * size + 1; const minExpectedTotal = hasMore ? offset + resultData.length + 1 : offset + resultData.length; @@ -1193,6 +1268,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ setSortInfo([{ columnKey: normalizedField, order: normalizedOrder, enabled: true }]); }, []); const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]); + const handleLastPage = useCallback((pageSize: number) => ( + fetchData(1, pageSize, { navigateToLastPage: true }) + ), [fetchData]); const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []); const handleApplyFilter = useCallback((conditions: FilterCondition[]) => { skipNextAutoFetchRef.current = false; @@ -1278,6 +1356,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ onReload={handleReload} onSort={handleSort} onPageChange={handlePageChange} + onLastPage={handleLastPage} pagination={pagination} onRequestTotalCount={preferManualTotalCount ? handleManualTotalCount : undefined} onCancelTotalCount={preferManualTotalCount ? handleCancelManualTotalCount : undefined}