(
paginationPageText={paginationPageText}
paginationPageSizeOptions={paginationPageSizeOptions}
showKnownPageCount={paginationHasKnownTotalPages}
+ manualTotalCountAvailable={prefersManualTotalCount && !!onRequestTotalCount}
+ totalCountLoading={pagination?.totalCountLoading}
onPageChange={onPageChange}
onPageSizeChange={handlePageSizeChange}
onV2PageStep={handleV2PageStep}
+ onToggleTotalCount={onRequestTotalCount ? handleToggleTotalCount : undefined}
translate={translateDataGrid}
/>
);
@@ -546,15 +557,6 @@ const renderDataTableView = () => (
}, wasClosed ? 350 : 0);
}, [mergedDisplayData, translateDataGrid]);
- const handleToggleTotalCount = useCallback(() => {
- if (!onRequestTotalCount) return;
- if (pagination?.totalCountLoading) {
- if (onCancelTotalCount) onCancelTotalCount();
- return;
- }
- onRequestTotalCount();
- }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]);
-
return (
{
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ id: 501 });
});
+ it('counts the exact total for a limited query result and updates pagination', async () => {
+ const firstPageRows = Array.from({ length: 500 }, (_item, index) => ({ id: index + 1 }));
+ backendApp.GenerateQueryID
+ .mockResolvedValueOnce('query-page-initial')
+ .mockResolvedValueOnce('query-total-count');
+ backendApp.DBQueryMulti
+ .mockResolvedValueOnce({
+ success: true,
+ data: [
+ { columns: ['id'], rows: firstPageRows, statementIndex: 1 },
+ ],
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [
+ { columns: ['__gonavi_total__'], rows: [{ __gonavi_total__: 1234 }], statementIndex: 1 },
+ ],
+ });
+
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ await act(async () => {
+ await findButton(renderer!, '运行').props.onClick();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(dataGridState.latestProps?.pagination).toMatchObject({
+ total: 1000,
+ totalKnown: false,
+ });
+ expect(dataGridState.latestProps?.onRequestTotalCount).toEqual(expect.any(Function));
+
+ await act(async () => {
+ await dataGridState.latestProps.onRequestTotalCount();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2);
+ expect(backendApp.DBQueryMulti).toHaveBeenLastCalledWith(
+ expect.anything(),
+ 'main',
+ 'SELECT COUNT(*) AS __gonavi_total__ FROM (SELECT id FROM users) __gonavi_query_count__',
+ 'query-total-count',
+ );
+ expect(dataGridState.latestProps?.pagination).toMatchObject({
+ total: 1234,
+ totalKnown: true,
+ totalCountLoading: false,
+ });
+ });
+
+ it('cancels a query-result total count without applying its late response', async () => {
+ const firstPageRows = Array.from({ length: 500 }, (_item, index) => ({ id: index + 1 }));
+ let resolveCount!: (value: any) => void;
+ const pendingCount = new Promise((resolve) => {
+ resolveCount = resolve;
+ });
+ backendApp.GenerateQueryID
+ .mockResolvedValueOnce('query-page-initial')
+ .mockResolvedValueOnce('query-total-count');
+ backendApp.CancelQuery.mockResolvedValueOnce({ success: true });
+ backendApp.DBQueryMulti
+ .mockResolvedValueOnce({
+ success: true,
+ data: [
+ { columns: ['id'], rows: firstPageRows, statementIndex: 1 },
+ ],
+ })
+ .mockImplementationOnce(() => pendingCount);
+
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await findButton(renderer!, '运行').props.onClick();
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ void dataGridState.latestProps.onRequestTotalCount();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(dataGridState.latestProps?.pagination?.totalCountLoading).toBe(true);
+ expect(dataGridState.latestProps?.onCancelTotalCount).toEqual(expect.any(Function));
+
+ await act(async () => {
+ await dataGridState.latestProps.onCancelTotalCount();
+ });
+ expect(backendApp.CancelQuery).toHaveBeenCalledWith('query-total-count');
+ expect(dataGridState.latestProps?.pagination).toMatchObject({
+ total: 1000,
+ totalKnown: false,
+ totalCountLoading: false,
+ });
+
+ await act(async () => {
+ resolveCount({
+ success: true,
+ data: [
+ { columns: ['__gonavi_total__'], rows: [{ __gonavi_total__: 9999 }] },
+ ],
+ });
+ await pendingCount;
+ await Promise.resolve();
+ });
+ expect(dataGridState.latestProps?.pagination).toMatchObject({
+ total: 1000,
+ totalKnown: false,
+ totalCountLoading: false,
+ });
+ });
+
+ it('does not apply an old total-count response to a newly executed result with the same key', async () => {
+ const firstQueryRows = Array.from({ length: 500 }, (_item, index) => ({ old_id: index + 1 }));
+ const secondQueryRows = Array.from({ length: 500 }, (_item, index) => ({ new_id: index + 1 }));
+ let resolveOldCount!: (value: any) => void;
+ const oldCount = new Promise((resolve) => {
+ resolveOldCount = resolve;
+ });
+ backendApp.GenerateQueryID
+ .mockResolvedValueOnce('query-first')
+ .mockResolvedValueOnce('query-old-total')
+ .mockResolvedValueOnce('query-second');
+ backendApp.CancelQuery.mockResolvedValue({ success: true });
+ backendApp.DBQueryMulti
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['old_id'], rows: firstQueryRows, statementIndex: 1 }],
+ })
+ .mockImplementationOnce(() => oldCount)
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['new_id'], rows: secondQueryRows, statementIndex: 1 }],
+ });
+
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await findButton(renderer!, '运行').props.onClick();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ void dataGridState.latestProps.onRequestTotalCount();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ editorState.value = 'SELECT new_id FROM new_users LIMIT 0,500';
+ await act(async () => {
+ await findButton(renderer!, '运行').props.onClick();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ new_id: 1 });
+
+ await act(async () => {
+ resolveOldCount({
+ success: true,
+ data: [{ columns: ['__gonavi_total__'], rows: [{ __gonavi_total__: 9999 }] }],
+ });
+ await oldCount;
+ await Promise.resolve();
+ });
+
+ expect(backendApp.CancelQuery).toHaveBeenCalledWith('query-old-total');
+ expect(dataGridState.latestProps?.pagination).toMatchObject({
+ total: 1000,
+ totalKnown: false,
+ });
+ });
+
+ it('keeps an exact counted total while navigating through non-final pages', async () => {
+ const firstPageRows = Array.from({ length: 500 }, (_item, index) => ({ id: index + 1 }));
+ const secondPageWithLookahead = Array.from({ length: 501 }, (_item, index) => ({ id: index + 501 }));
+ backendApp.GenerateQueryID
+ .mockResolvedValueOnce('query-initial')
+ .mockResolvedValueOnce('query-total')
+ .mockResolvedValueOnce('query-page-2');
+ backendApp.DBQueryMulti
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['id'], rows: firstPageRows, statementIndex: 1 }],
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['__gonavi_total__'], rows: [{ __gonavi_total__: 1234 }] }],
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{ columns: ['id'], rows: secondPageWithLookahead, statementIndex: 1 }],
+ });
+
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await findButton(renderer!, '运行').props.onClick();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await dataGridState.latestProps.onRequestTotalCount();
+ await Promise.resolve();
+ });
+ expect(dataGridState.latestProps?.pagination).toMatchObject({ total: 1234, totalKnown: true });
+
+ await act(async () => {
+ await dataGridState.latestProps.onPageChange(2, 500);
+ await Promise.resolve();
+ });
+ expect(dataGridState.latestProps?.pagination).toMatchObject({
+ current: 2,
+ total: 1234,
+ totalKnown: true,
+ });
+ });
+
it('runs SQL editor data-changing CTEs through a pending managed transaction', async () => {
const sql = 'WITH moved AS (DELETE FROM audit_logs WHERE created_at < NOW() RETURNING id) SELECT * FROM moved';
backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({
diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx
index 5964916d..e863631d 100644
--- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx
+++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx
@@ -3023,6 +3023,168 @@ describe('QueryEditor external SQL save', () => {
expect(editorSource).toContain('setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY)');
});
+ it('connects each query result sort state and callback to DataGrid', async () => {
+ const onResultSort = vi.fn();
+ const sortInfo = [{ columnKey: 'name', order: 'ascend', enabled: true }];
+ let renderer!: ReactTestRenderer;
+
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+
+ expect(dataGridState.latestProps?.sortInfoExternal).toEqual(sortInfo);
+ expect(dataGridState.latestProps?.onSort).toEqual(expect.any(Function));
+
+ const serialized = JSON.stringify([{ columnKey: 'id', order: 'descend', enabled: true }]);
+ dataGridState.latestProps.onSort(serialized, '');
+ expect(onResultSort).toHaveBeenCalledWith('result-1', serialized, '');
+ renderer.unmount();
+ });
+
+ it('sorts complete query results locally and restores execution order when cleared', async () => {
+ const query = "select 3 as id, 'Zulu' as name union all select 1, 'Alpha' union all select 2, 'Alpha';";
+ backendApp.DBQueryMulti.mockResolvedValueOnce({
+ success: true,
+ data: [{
+ columns: ['id', 'name'],
+ rows: [
+ { id: 3, name: 'Zulu' },
+ { id: 1, name: 'Alpha' },
+ { id: 2, name: 'Alpha' },
+ ],
+ }],
+ });
+ let renderer!: ReactTestRenderer;
+
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await findButton(renderer, '运行').props.onClick();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Zulu', 'Alpha', 'Alpha']);
+ expect(dataGridState.latestProps?.sortInfoExternal).toEqual([]);
+
+ await act(async () => {
+ await dataGridState.latestProps.onSort(JSON.stringify([
+ { columnKey: 'name', order: 'ascend', enabled: true },
+ { columnKey: 'id', order: 'descend', enabled: true },
+ ]), '');
+ });
+
+ expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Alpha', 'Alpha', 'Zulu']);
+ expect(dataGridState.latestProps?.data.map((row: any) => row.__gonavi_row_key__)).toEqual([2, 1, 0]);
+ expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await dataGridState.latestProps.onSort('[]', '');
+ });
+
+ expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Zulu', 'Alpha', 'Alpha']);
+ expect(dataGridState.latestProps?.data.map((row: any) => row.__gonavi_row_key__)).toEqual([0, 1, 2]);
+ expect(dataGridState.latestProps?.sortInfoExternal).toEqual([]);
+ renderer.unmount();
+ });
+
+ it('requeries the first page with outer ordering when a pageable result is sorted', async () => {
+ storeState.queryOptions.maxRows = 2;
+ const query = 'select id, name from (select id, name from users) q;';
+ backendApp.DBQueryMulti
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{
+ columns: ['id', 'name'],
+ rows: [{ id: 2, name: 'Beta' }, { id: 1, name: 'Alpha' }],
+ }],
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{
+ columns: ['id', 'name'],
+ rows: [{ id: 4, name: 'Delta' }, { id: 3, name: 'Charlie' }],
+ }],
+ })
+ .mockResolvedValueOnce({
+ success: true,
+ data: [{
+ columns: ['id', 'name'],
+ rows: [
+ { id: 1, name: 'Alpha' },
+ { id: 2, name: 'Beta' },
+ { id: 3, name: 'Charlie' },
+ ],
+ }],
+ });
+ let renderer!: ReactTestRenderer;
+
+ await act(async () => {
+ renderer = create();
+ });
+ await act(async () => {
+ await findButton(renderer, '运行').props.onClick();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 1, pageSize: 2 });
+
+ await act(async () => {
+ await dataGridState.latestProps.onPageChange(2, 2);
+ });
+ expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 2, pageSize: 2 });
+
+ await act(async () => {
+ await dataGridState.latestProps.onSort(JSON.stringify([
+ { columnKey: 'name', order: 'ascend', enabled: true },
+ ]), '');
+ });
+
+ expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(3);
+ const sortedPageSql = String(backendApp.DBQueryMulti.mock.calls[2][2]);
+ expect(sortedPageSql).toContain('AS __gonavi_query_page__ ORDER BY `name` ASC LIMIT 3 OFFSET 0');
+ expect(dataGridState.latestProps?.pagination).toMatchObject({ current: 1, pageSize: 2 });
+ expect(dataGridState.latestProps?.sortInfoExternal).toEqual([
+ { columnKey: 'name', order: 'ascend', enabled: true },
+ ]);
+ expect(dataGridState.latestProps?.data.map((row: any) => row.name)).toEqual(['Alpha', 'Beta']);
+ renderer.unmount();
+ });
+
it('does not render the embedded sql execution log tab in legacy UI', () => {
const renderResultsPanel = (isV2Ui: boolean) => create(
{
onCloseAllResultTabs={vi.fn()}
onReloadResult={vi.fn()}
onResultPageChange={vi.fn()}
+ onResultSort={vi.fn()}
onDiagnoseExecutionError={vi.fn()}
/>,
);
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 221e2cf2..84cf0cc9 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -11,6 +11,7 @@ import { GONAVI_ROW_KEY } from './DataGrid';
import { EventsOn } from '../../wailsjs/runtime';
import { findConnectionMutatingStatements } from '../utils/connectionReadOnly';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
+import type { GridSortInfoItem } from '../utils/dataGridSort';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, normalizeShortcutCombo, resolveShortcutBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
@@ -19,8 +20,10 @@ import { isPostgresSchemaDialect } from '../utils/connectionDriverType';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
import {
+ buildQueryResultCountSql,
buildQueryResultPageSql,
createInitialQueryResultPagination,
+ parseQueryResultTotalCount,
resolveQueryResultPaginationTotal,
} from '../utils/queryResultPagination';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
@@ -1107,6 +1110,98 @@ const resetSharedQueryEditorMetadata = () => {
clearRecord(sharedLazyTablesInFlight);
};
+const parseQueryResultSortInfo = (field: string, order: string): GridSortInfoItem[] => {
+ let candidates: unknown[] = [];
+ try {
+ const parsed = JSON.parse(field);
+ if (Array.isArray(parsed)) candidates = parsed;
+ } catch {
+ // Compatibility with the legacy single-column callback shape.
+ }
+ if (candidates.length === 0) {
+ candidates = [{ columnKey: field, order, enabled: true }];
+ }
+
+ const normalized: GridSortInfoItem[] = [];
+ const seen = new Set();
+ candidates.forEach((candidate) => {
+ if (!candidate || typeof candidate !== 'object') return;
+ const item = candidate as Record;
+ const columnKey = String(item.columnKey || '').trim();
+ const normalizedOrder = item.order === 'ascend' || item.order === 'descend'
+ ? item.order
+ : '';
+ const dedupeKey = columnKey.toLowerCase();
+ if (!columnKey || !normalizedOrder || seen.has(dedupeKey)) return;
+ seen.add(dedupeKey);
+ normalized.push({
+ columnKey,
+ order: normalizedOrder,
+ enabled: item.enabled !== false,
+ });
+ });
+ return normalized;
+};
+
+const compareQueryResultValues = (left: unknown, right: unknown): number => {
+ if (Object.is(left, right)) return 0;
+ if (left === null || left === undefined) return -1;
+ if (right === null || right === undefined) return 1;
+ if (typeof left === 'bigint' && typeof right === 'bigint') {
+ return left < right ? -1 : 1;
+ }
+ if (typeof left === 'number' && typeof right === 'number') {
+ if (Number.isNaN(left)) return Number.isNaN(right) ? 0 : -1;
+ if (Number.isNaN(right)) return 1;
+ return left < right ? -1 : left > right ? 1 : 0;
+ }
+ if (typeof left === 'boolean' && typeof right === 'boolean') {
+ return Number(left) - Number(right);
+ }
+ return String(left).localeCompare(String(right), undefined, {
+ numeric: true,
+ sensitivity: 'base',
+ });
+};
+
+const compareQueryResultOriginalOrder = (
+ left: Record,
+ right: Record,
+ leftIndex: number,
+ rightIndex: number,
+): number => {
+ const leftKey = left?.[GONAVI_ROW_KEY];
+ const rightKey = right?.[GONAVI_ROW_KEY];
+ const leftNumber = Number(leftKey);
+ const rightNumber = Number(rightKey);
+ if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber !== rightNumber) {
+ return leftNumber - rightNumber;
+ }
+ const keyOrder = String(leftKey ?? '').localeCompare(String(rightKey ?? ''), undefined, { numeric: true });
+ return keyOrder || leftIndex - rightIndex;
+};
+
+const sortCompleteQueryResultRows = (
+ rows: any[],
+ sortInfo: GridSortInfoItem[],
+): any[] => {
+ const activeSortInfo = sortInfo.filter((item) => item.enabled !== false);
+ return rows
+ .map((row, index) => ({ row, index }))
+ .sort((left, right) => {
+ for (const item of activeSortInfo) {
+ const valueOrder = compareQueryResultValues(
+ left.row?.[item.columnKey],
+ right.row?.[item.columnKey],
+ );
+ if (valueOrder !== 0) {
+ return item.order === 'descend' ? -valueOrder : valueOrder;
+ }
+ }
+ return compareQueryResultOriginalOrder(left.row, right.row, left.index, right.index);
+ })
+ .map(({ row }) => row);
+};
const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const appearance = useStore(state => state.appearance);
@@ -1142,6 +1237,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const [sqlSnippetPickerKeyword, setSqlSnippetPickerKeyword] = useState('');
const runSeqRef = useRef(0);
const currentQueryIdRef = useRef('');
+ const resultTotalCountSeqRef = useRef(0);
+ const resultTotalCountRequestsRef = useRef>({});
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save');
const [saveForm] = Form.useForm();
@@ -1149,6 +1246,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// Database Selection
const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId);
const [currentDb, setCurrentDb] = useState(tab.dbName || '');
+ const resultTotalCountContextRef = useRef(`${tab.connectionId}\u0000${tab.dbName || ''}`);
const [dbList, setDbList] = useState([]);
const [isTextToSqlModalOpen, setIsTextToSqlModalOpen] = useState(false);
const [textToSqlInstruction, setTextToSqlInstruction] = useState('');
@@ -5970,8 +6068,179 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
- const handleResultPageChange = async (resultKey: string, page: number, pageSize: number) => {
- const target = resultSets.find((item) => item.key === resultKey);
+ const handleRequestResultTotalCount = async (resultKey: string) => {
+ const target = resultSetsRef.current.find((item) => item.key === resultKey);
+ if (!target?.page?.baseSql || !currentDb || resultTotalCountRequestsRef.current[resultKey]) return;
+ const conn = connections.find(c => c.id === currentConnectionId);
+ if (!conn) return;
+ const countSql = buildQueryResultCountSql(target.page.baseSql);
+ if (!countSql) return;
+ const config = {
+ ...conn.config,
+ port: Number(conn.config.port),
+ password: conn.config.password || '',
+ database: conn.config.database || '',
+ useSSH: conn.config.useSSH || false,
+ ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
+ timeout: Math.max(Number(conn.config.timeout) || 30, 120),
+ };
+ const normalizedDbType = String(resolveSqlDialect(
+ String(config.type || 'mysql'),
+ String((config as any).driver || ''),
+ { oceanBaseProtocol: String((config as any).oceanBaseProtocol || '') },
+ )).toLowerCase();
+ const sequence = ++resultTotalCountSeqRef.current;
+ const requestRunSequence = runSeqRef.current;
+ resultTotalCountRequestsRef.current[resultKey] = { sequence, queryId: '' };
+ setResultSets(prev => prev.map(rs =>
+ rs.key === resultKey && rs.page
+ ? { ...rs, page: { ...rs.page, totalCountLoading: true, totalCountCancelled: false } }
+ : rs
+ ));
+ const countStartedAt = Date.now();
+ const isCurrentRequest = () => {
+ if (resultTotalCountRequestsRef.current[resultKey]?.sequence !== sequence) return false;
+ if (runSeqRef.current !== requestRunSequence) return false;
+ const currentResult = resultSetsRef.current.find((item) => item.key === resultKey);
+ return currentResult?.page?.baseSql === target.page?.baseSql;
+ };
+ const finishLoading = (cancelled = false) => {
+ if (!isCurrentRequest()) return;
+ delete resultTotalCountRequestsRef.current[resultKey];
+ setResultSets(prev => prev.map(rs =>
+ rs.key === resultKey && rs.page
+ ? { ...rs, page: { ...rs.page, totalCountLoading: false, totalCountCancelled: cancelled } }
+ : rs
+ ));
+ };
+
+ try {
+ let queryId: string;
+ try {
+ queryId = await GenerateQueryID();
+ } catch {
+ queryId = `query-total-${uuidv4()}`;
+ }
+ if (!isCurrentRequest()) return;
+ resultTotalCountRequestsRef.current[resultKey] = { sequence, queryId };
+ const res = await executeSqlEditorMultiQuery(
+ config,
+ currentDb,
+ countSql,
+ queryId,
+ [countSql],
+ normalizedDbType,
+ );
+ const duration = Date.now() - countStartedAt;
+ addSqlLog({
+ id: `log-${Date.now()}-query-total-count`,
+ timestamp: Date.now(),
+ sql: countSql,
+ status: res?.success ? 'success' : 'error',
+ duration,
+ message: res?.success ? '' : String(res?.message || translate('data_viewer.message.total_count_failed')),
+ dbName: currentDb,
+ });
+ if (!isCurrentRequest()) return;
+ if (!res?.success) {
+ finishLoading();
+ message.error(String(res?.message || translate('data_viewer.message.total_count_failed')));
+ return;
+ }
+ const resultSetData = Array.isArray(res.data) ? res.data[0] : null;
+ const countRow = Array.isArray(resultSetData?.rows) ? resultSetData.rows[0] : null;
+ const total = parseQueryResultTotalCount(countRow);
+ if (total === null) {
+ finishLoading();
+ message.error(translate('data_viewer.message.total_count_parse_failed'));
+ return;
+ }
+
+ delete resultTotalCountRequestsRef.current[resultKey];
+ setResultSets(prev => prev.map(rs =>
+ rs.key === resultKey && rs.page
+ ? {
+ ...rs,
+ page: {
+ ...rs.page,
+ total,
+ totalKnown: true,
+ totalCountLoading: false,
+ totalCountCancelled: false,
+ },
+ }
+ : rs
+ ));
+ } catch (error: any) {
+ if (!isCurrentRequest()) return;
+ addSqlLog({
+ id: `log-${Date.now()}-query-total-count-error`,
+ timestamp: Date.now(),
+ sql: countSql,
+ status: 'error',
+ duration: Date.now() - countStartedAt,
+ message: String(error?.message || error || translate('common.unknown')),
+ dbName: currentDb,
+ });
+ finishLoading();
+ message.error(translate('data_viewer.message.total_count_failed_detail', {
+ detail: String(error?.message || error || translate('common.unknown')),
+ }));
+ }
+ };
+
+ const cancelResultTotalCountRequests = async (resultKeys: string[]) => {
+ const uniqueKeys = Array.from(new Set(resultKeys));
+ const pendingRequests = uniqueKeys
+ .map((key) => ({ key, request: resultTotalCountRequestsRef.current[key] }))
+ .filter((item) => Boolean(item.request));
+ if (pendingRequests.length === 0) return;
+ pendingRequests.forEach(({ key }) => {
+ delete resultTotalCountRequestsRef.current[key];
+ });
+ const pendingKeySet = new Set(pendingRequests.map(({ key }) => key));
+ setResultSets(prev => prev.map(rs =>
+ pendingKeySet.has(rs.key) && rs.page
+ ? { ...rs, page: { ...rs.page, totalCountLoading: false, totalCountCancelled: true } }
+ : rs
+ ));
+ await Promise.all(pendingRequests.map(async ({ request }) => {
+ if (!request?.queryId) return;
+ try {
+ await CancelQuery(request.queryId);
+ } catch {
+ // The query may have completed between the local cancellation and the backend call.
+ }
+ }));
+ };
+
+ useEffect(() => {
+ const nextContext = `${currentConnectionId}\u0000${currentDb}`;
+ if (resultTotalCountContextRef.current === nextContext) return;
+ resultTotalCountContextRef.current = nextContext;
+ void cancelResultTotalCountRequests(Object.keys(resultTotalCountRequestsRef.current));
+ }, [currentConnectionId, currentDb]);
+
+ useEffect(() => () => {
+ const requests = Object.values(resultTotalCountRequestsRef.current);
+ resultTotalCountRequestsRef.current = {};
+ requests.forEach((request) => {
+ if (!request.queryId) return;
+ void CancelQuery(request.queryId).catch(() => undefined);
+ });
+ }, []);
+
+ const handleCancelResultTotalCount = async (resultKey: string) => {
+ await cancelResultTotalCountRequests([resultKey]);
+ };
+
+ const handleResultPageChange = async (
+ resultKey: string,
+ page: number,
+ pageSize: number,
+ sortInfoOverride?: GridSortInfoItem[],
+ ) => {
+ const target = resultSetsRef.current.find((item) => item.key === resultKey);
if (!target?.page?.baseSql || !currentDb) return;
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
@@ -5994,9 +6263,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
baseSql: target.page.baseSql,
dbType: normalizedDbType,
driver,
+ oceanBaseProtocol: String((config as any).oceanBaseProtocol || ''),
page: safePage,
pageSize: safePageSize,
lookahead: true,
+ sortInfo: sortInfoOverride || target.sortInfo || [],
});
try {
@@ -6050,25 +6321,29 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
rowCount: rows.length,
hasNext,
});
- setResultSets(prev => prev.map(rs =>
- rs.key === resultKey && rs.page
- ? {
- ...rs,
- rows,
- columns: cols,
- messages: pageMessages,
- resultType: 'grid',
- truncated: false,
- page: {
- ...rs.page,
- current: safePage,
- pageSize: safePageSize,
- ...totalState,
- loading: false,
- },
- }
- : rs
- ));
+ setResultSets(prev => prev.map(rs => {
+ if (rs.key !== resultKey || !rs.page) return rs;
+ const hasExactTotal = rs.page.totalKnown === true
+ && Number.isFinite(Number(rs.page.total))
+ && Number(rs.page.total) >= 0;
+ return {
+ ...rs,
+ rows,
+ columns: cols,
+ messages: pageMessages,
+ resultType: 'grid',
+ truncated: false,
+ page: {
+ ...rs.page,
+ current: safePage,
+ pageSize: safePageSize,
+ ...(hasExactTotal
+ ? { total: rs.page.total, totalKnown: true }
+ : totalState),
+ loading: false,
+ },
+ };
+ }));
} catch (err: any) {
message.error(translate('query_editor.message.page_query_failed', {
error: formatSqlExecutionError(err?.message || err || translate('common.unknown'), { translate }),
@@ -6083,6 +6358,30 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
};
+ const handleResultSort = async (resultKey: string, field: string, order: string) => {
+ const nextSortInfo = parseQueryResultSortInfo(field, order);
+ const target = resultSetsRef.current.find((item) => item.key === resultKey);
+ if (!target) return;
+
+ if (target.page) {
+ setResultSets(prev => prev.map(rs => (
+ rs.key === resultKey ? { ...rs, sortInfo: nextSortInfo } : rs
+ )));
+ await handleResultPageChange(resultKey, 1, target.page.pageSize, nextSortInfo);
+ return;
+ }
+
+ setResultSets(prev => prev.map(rs => (
+ rs.key === resultKey
+ ? {
+ ...rs,
+ rows: sortCompleteQueryResultRows(rs.rows, nextSortInfo),
+ sortInfo: nextSortInfo,
+ }
+ : rs
+ )));
+ };
+
const handleRun = async () => {
const currentQuery = getCurrentQuery();
if (!currentQuery.trim()) return;
@@ -6097,6 +6396,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
message.error(translate('query_editor.message.select_database_first'));
return;
}
+ await cancelResultTotalCountRequests(Object.keys(resultTotalCountRequestsRef.current));
// 如果已有查询在运行,先取消它
if (currentQueryIdRef.current) {
try {
@@ -7841,6 +8141,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
const handleCloseResult = (key: string) => {
+ void cancelResultTotalCountRequests([key]);
setResultSets(prev => {
const idx = prev.findIndex(r => r.key === key);
if (idx < 0) return prev;
@@ -7856,6 +8157,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
const replaceResultSetsAfterMenuClose = (next: ResultSet[], preferredKey?: string) => {
+ const nextKeys = new Set(next.map((result) => result.key));
+ const removedCountKeys = Object.keys(resultTotalCountRequestsRef.current)
+ .filter((key) => !nextKeys.has(key));
+ void cancelResultTotalCountRequests(removedCountKeys);
setResultSets(next);
setActiveResultKey(prevActive => {
if (preferredKey && next.some(result => result.key === preferredKey)) return preferredKey;
@@ -7882,8 +8187,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
const closeAllResultTabs = () => {
- setResultSets([]);
- setActiveResultKey('');
+ replaceResultSetsAfterMenuClose([]);
};
const openResultInWindow = (
@@ -8154,6 +8458,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
onOpenResultInWindow={openResultInWindow}
onReloadResult={handleReloadResult}
onResultPageChange={handleResultPageChange}
+ onResultSort={handleResultSort}
+ onRequestResultTotalCount={handleRequestResultTotalCount}
+ onCancelResultTotalCount={handleCancelResultTotalCount}
onDiagnoseExecutionError={handleDiagnoseExecutionError}
onCompareResult={(resultKey) => {
setResultDiffAnchorKey(resultKey);
diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx
index 3464d680..54b156d6 100644
--- a/frontend/src/components/QueryEditorResultsPanel.tsx
+++ b/frontend/src/components/QueryEditorResultsPanel.tsx
@@ -3,6 +3,7 @@ import { Button, Dropdown, Tabs, Tooltip, message, type MenuProps } from 'antd';
import { BugOutlined, CloseOutlined, CopyOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons';
import type { EditRowLocator } from '../utils/rowLocator';
+import type { GridSortInfoItem } from '../utils/dataGridSort';
import type { QueryResultPaginationState } from '../utils/queryResultPagination';
import { filterColumnNamesByGlobalHiddenColumns, useGlobalHiddenColumns } from '../utils/globalHiddenColumns';
import { buildQueryResultColumnPinScope } from '../utils/queryResultColumnPinScope';
@@ -45,6 +46,7 @@ export type QueryEditorResultSet = {
showRowNumberColumn?: boolean;
truncated?: boolean;
pkLoading?: boolean;
+ sortInfo?: GridSortInfoItem[];
page?: QueryResultPaginationState & { loading?: boolean };
};
@@ -69,6 +71,9 @@ interface QueryEditorResultsPanelProps {
onOpenResultInWindow?: (key: string, preferred?: OpenResultInWindowPreferred) => void;
onReloadResult: (key: string, sql: string) => void;
onResultPageChange: (key: string, page: number, pageSize: number) => void;
+ onResultSort: (key: string, field: string, order: string) => void;
+ onRequestResultTotalCount?: (key: string) => void;
+ onCancelResultTotalCount?: (key: string) => void;
onDiagnoseExecutionError: () => void;
onCompareResult?: (resultKey: string) => void;
}
@@ -102,6 +107,9 @@ const QueryEditorResultsPanel: React.FC = ({
onOpenResultInWindow,
onReloadResult,
onResultPageChange,
+ onResultSort,
+ onRequestResultTotalCount,
+ onCancelResultTotalCount,
onDiagnoseExecutionError,
onCompareResult,
}) => {
@@ -483,8 +491,18 @@ const QueryEditorResultsPanel: React.FC = ({
pageSize: rs.page.pageSize,
total: rs.page.total,
totalKnown: rs.page.totalKnown,
+ totalCountLoading: rs.page.totalCountLoading,
+ totalCountCancelled: rs.page.totalCountCancelled,
} : undefined}
onPageChange={rs.page ? ((page, size) => onResultPageChange(rs.key, page, size)) : undefined}
+ onSort={(field, order) => onResultSort(rs.key, field, order)}
+ sortInfoExternal={rs.sortInfo || []}
+ onRequestTotalCount={rs.page && onRequestResultTotalCount
+ ? (() => onRequestResultTotalCount(rs.key))
+ : undefined}
+ onCancelTotalCount={rs.page && onCancelResultTotalCount
+ ? (() => onCancelResultTotalCount(rs.key))
+ : undefined}
readOnly={rs.readOnly}
toolbarExtraActions={resolvedActiveResultKey === rs.key ? toolbarHideButton : null}
/>
diff --git a/frontend/src/utils/queryResultPagination.test.ts b/frontend/src/utils/queryResultPagination.test.ts
index d073b7a9..0ea758df 100644
--- a/frontend/src/utils/queryResultPagination.test.ts
+++ b/frontend/src/utils/queryResultPagination.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
+ buildQueryResultCountSql,
buildQueryResultPageSql,
createInitialQueryResultPagination,
+ parseQueryResultTotalCount,
resolveQueryResultPaginationTotal,
} from './queryResultPagination';
@@ -74,6 +76,36 @@ describe('queryResultPagination', () => {
})).toBe('SELECT * FROM (SELECT id FROM users) AS __gonavi_query_page__ LIMIT 501 OFFSET 500');
});
+ it('sorts the wrapped MySQL result before applying pagination', () => {
+ expect(buildQueryResultPageSql({
+ baseSql: 'SELECT id, display_name FROM users',
+ dbType: 'mysql',
+ page: 2,
+ pageSize: 100,
+ lookahead: true,
+ sortInfo: [
+ { columnKey: 'display_name', order: 'ascend', enabled: true },
+ { columnKey: 'id', order: 'descend', enabled: true },
+ ],
+ })).toBe(
+ 'SELECT * FROM (SELECT id, display_name FROM users) AS __gonavi_query_page__ ORDER BY `display_name` ASC, `id` DESC LIMIT 101 OFFSET 100',
+ );
+ });
+
+ it('uses Oracle pagination and outer sorting for OceanBase Oracle protocol', () => {
+ expect(buildQueryResultPageSql({
+ baseSql: 'SELECT id, DISPLAY_NAME FROM users',
+ dbType: 'oceanbase',
+ oceanBaseProtocol: 'oracle',
+ page: 2,
+ pageSize: 50,
+ lookahead: true,
+ sortInfo: [{ columnKey: 'DISPLAY_NAME', order: 'ascend', enabled: true }],
+ })).toBe(
+ 'SELECT * FROM (SELECT "__gonavi_page__".*, ROWNUM "__gonavi_rn__" FROM (SELECT * FROM (SELECT id, DISPLAY_NAME FROM users) "__gonavi_query_page__" ORDER BY "DISPLAY_NAME" ASC) "__gonavi_page__" WHERE ROWNUM <= 101) WHERE "__gonavi_rn__" > 50',
+ );
+ });
+
it('marks the last full lookahead page as an exact total', () => {
expect(resolveQueryResultPaginationTotal({
current: 2,
@@ -82,4 +114,18 @@ describe('queryResultPagination', () => {
hasNext: false,
})).toEqual({ total: 1000, totalKnown: true });
});
+
+ it('builds a portable total-count query and removes only the top-level ordering', () => {
+ expect(buildQueryResultCountSql(
+ 'SELECT id FROM (SELECT id FROM users ORDER BY created_at) nested ORDER BY id DESC;',
+ )).toBe(
+ 'SELECT COUNT(*) AS __gonavi_total__ FROM (SELECT id FROM (SELECT id FROM users ORDER BY created_at) nested) __gonavi_query_count__',
+ );
+ });
+
+ it('parses total counts case-insensitively without losing large safe integers', () => {
+ expect(parseQueryResultTotalCount({ __GONAVI_TOTAL__: '1234' })).toBe(1234);
+ expect(parseQueryResultTotalCount({ count: BigInt(42) })).toBe(42);
+ expect(parseQueryResultTotalCount({ total: '-1' })).toBeNull();
+ });
});
diff --git a/frontend/src/utils/queryResultPagination.ts b/frontend/src/utils/queryResultPagination.ts
index 1ac55bef..e0a2a46b 100644
--- a/frontend/src/utils/queryResultPagination.ts
+++ b/frontend/src/utils/queryResultPagination.ts
@@ -1,4 +1,4 @@
-import { buildPaginatedSelectSQL } from './sql';
+import { buildOrderBySQL, buildPaginatedSelectSQL } from './sql';
import { findTopLevelKeyword, getLeadingKeyword, splitSqlTail } from './queryAutoLimit';
import { resolveSqlDialect } from './sqlDialect';
@@ -7,6 +7,8 @@ export type QueryResultPaginationState = {
pageSize: number;
total: number;
totalKnown?: boolean;
+ totalCountLoading?: boolean;
+ totalCountCancelled?: boolean;
baseSql: string;
exportAllSql?: string;
};
@@ -22,6 +24,26 @@ const normalizePositiveInteger = (value: unknown): number => {
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
+const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
+
+const parseNonNegativeSafeInteger = (value: unknown): number | null => {
+ if (typeof value === 'bigint') {
+ return value >= 0n && value <= MAX_SAFE_INTEGER_BIGINT ? Number(value) : null;
+ }
+ if (typeof value === 'number') {
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
+ }
+ if (typeof value !== 'string') return null;
+ const text = value.trim();
+ if (!/^[+]?[0-9]+$/.test(text)) return null;
+ try {
+ const parsed = BigInt(text);
+ return parsed <= MAX_SAFE_INTEGER_BIGINT ? Number(parsed) : null;
+ } catch {
+ return null;
+ }
+};
+
const normalizeSqlForComparison = (sql: string): string => (
String(sql || '')
.replace(/\s+/g, ' ')
@@ -102,24 +124,57 @@ const resolveWrappedBaseSql = (dbType: string, baseSql: string): string => {
return `SELECT * FROM (${base}) AS __gonavi_query_page__`;
};
+export const buildQueryResultCountSql = (baseSql: string): string => {
+ const mainSql = splitSqlTail(String(baseSql || '')).main.trim();
+ if (!mainSql) return '';
+ const orderByPos = findTopLevelKeyword(mainSql, 'order by');
+ const countBaseSql = (orderByPos >= 0 ? mainSql.slice(0, orderByPos) : mainSql).trim();
+ if (!countBaseSql) return '';
+ return `SELECT COUNT(*) AS __gonavi_total__ FROM (${countBaseSql}) __gonavi_query_count__`;
+};
+
+export const parseQueryResultTotalCount = (row: unknown): number | null => {
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null;
+ const entries = Object.entries(row as Record);
+ if (entries.length === 0) return null;
+
+ for (const [key, value] of entries) {
+ const normalizedKey = key.trim().toLowerCase();
+ if (normalizedKey === '__gonavi_total__' || normalizedKey === 'total' || normalizedKey.includes('count')) {
+ const parsed = parseNonNegativeSafeInteger(value);
+ if (parsed !== null) return parsed;
+ }
+ }
+ for (const [, value] of entries) {
+ const parsed = parseNonNegativeSafeInteger(value);
+ if (parsed !== null) return parsed;
+ }
+ return null;
+};
+
export const buildQueryResultPageSql = (params: {
baseSql: string;
dbType: string;
driver?: string;
+ oceanBaseProtocol?: string;
page: number;
pageSize: number;
lookahead?: boolean;
+ sortInfo?: Array<{ columnKey: string; order: string; enabled?: boolean }>;
}): string => {
const pageSize = normalizePositiveInteger(params.pageSize);
if (pageSize <= 0) return String(params.baseSql || '').trim();
const page = Math.max(1, Math.floor(Number(params.page) || 1));
const limit = params.lookahead ? pageSize + 1 : pageSize;
const offset = (page - 1) * pageSize;
- const dialect = resolveSqlDialect(params.dbType || 'mysql', params.driver || '');
+ const dialect = resolveSqlDialect(params.dbType || 'mysql', params.driver || '', {
+ oceanBaseProtocol: params.oceanBaseProtocol || '',
+ });
+ const orderBySql = buildOrderBySQL(dialect, params.sortInfo || []);
return buildPaginatedSelectSQL(
dialect,
resolveWrappedBaseSql(dialect, params.baseSql),
- '',
+ orderBySql,
limit,
offset,
);
diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css
index 6a9f77cb..59d8fea2 100644
--- a/frontend/src/v2-theme.css
+++ b/frontend/src/v2-theme.css
@@ -4800,6 +4800,16 @@ body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap [data-grid-paginatio
white-space: nowrap;
}
+body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap [data-grid-pagination-first="true"].ant-btn,
+body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap [data-grid-pagination-last="true"].ant-btn {
+ width: auto !important;
+ min-width: max-content !important;
+ max-width: none !important;
+ gap: 3px;
+ padding: 0 7px !important;
+ white-space: nowrap;
+}
+
body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap [data-grid-pagination-total-count="true"].ant-btn .ant-btn-icon {
margin-inline-end: 3px !important;
}
@@ -4819,6 +4829,11 @@ body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap .ant-btn:disabled {
color: var(--gn-fg-5) !important;
}
+body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap [data-grid-pagination-first="true"].ant-btn:disabled,
+body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap [data-grid-pagination-last="true"].ant-btn:disabled {
+ color: var(--gn-fg-4) !important;
+}
+
body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap .ant-btn .ant-btn-icon,
body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap .ant-btn .anticon,
body[data-ui-version="v2"] .gn-v2-data-grid-pagination-wrap .ant-btn svg {