diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index cabda244..13ee96ac 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -1084,6 +1084,62 @@ describe('DataGrid DDL interactions', () => { renderer!.unmount(); }); + it('allows sorter arrow clicks through while cell edit mode is active', async () => { + messageApi.info.mockResolvedValue(undefined); + const onSort = vi.fn(); + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + await act(async () => { + renderer!.root.findByType(DataGridToolbarFrame).props.onToggleCellEditMode(); + }); + await waitForEffects(); + + const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name'); + const headerProps = nameColumn.onHeaderCell(nameColumn); + const upArrow = { + getBoundingClientRect: () => ({ left: 100, right: 112, top: 20, bottom: 32 }), + }; + const event = { + target: { closest: vi.fn(() => null) }, + currentTarget: { + querySelector: vi.fn((selector: string) => selector.includes('sorter-up') ? upArrow : null), + }, + clientX: 106, + clientY: 26, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + headerProps.onClickCapture(event); + }); + + const toolbar = renderer!.root.findByType(DataGridToolbarFrame); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopPropagation).not.toHaveBeenCalled(); + expect(toolbar.props.selectedCellsSize).toBe(0); + renderer!.unmount(); + }); + it('opens the v2 column header context menu from table headers', async () => { setCurrentLanguage('en-US'); storeState.appearance.uiVersion = 'v2'; @@ -1136,6 +1192,56 @@ describe('DataGrid DDL interactions', () => { renderer!.unmount(); }); + it('applies ascending sort from the v2 column header context menu', async () => { + setCurrentLanguage('zh-CN'); + storeState.appearance.uiVersion = 'v2'; + const onSort = vi.fn(); + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + , + ); + }); + await waitForEffects(); + + const nameColumn = testRenderState.latestColumns.find((column) => column.key === 'name'); + const headerProps = nameColumn.onHeaderCell(nameColumn); + await act(async () => { + headerProps.onContextMenu({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 120, + clientY: 88, + }); + }); + + const ascendingButton = renderer!.root.findAll((node) => ( + node.type === 'button' + && textContent(node) === t('data_grid.context_menu.sort_ascending') + ))[0]; + expect(ascendingButton).toBeTruthy(); + + await act(async () => { + ascendingButton.props.onClick({ preventDefault: vi.fn(), stopPropagation: vi.fn() }); + }); + + expect(onSort).toHaveBeenCalledWith( + JSON.stringify([{ columnKey: 'name', order: 'ascend', enabled: true }]), + '', + ); + renderer!.unmount(); + }); + it('pins a read-only query-result column with an independent pin scope', async () => { storeState.appearance.uiVersion = 'v2'; const columnPinScope = 'query-result:1a2b3c4d'; diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 3144602b..6cf2c0a2 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -1032,6 +1032,12 @@ describe('DataGrid layout', () => { expect(css).toMatch(/\[data-grid-pagination-total-count="true"\]\.ant-btn \.ant-btn-icon \{[\s\S]*?margin-inline-end: 3px !important;/); }); + it('passes the real total-count handler from DataGridShell to the pagination bar', () => { + const shellSource = readDataGridShellSource(); + + expect(shellSource).toMatch(/ { const source = readDataGridSource(); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 35003026..e39b41c7 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -2575,6 +2575,22 @@ const DataGrid: React.FC = ({ if (eventTarget?.closest?.('[data-grid-column-filter-popover="true"]')) return; if (eventTarget?.closest?.('.ant-select-dropdown')) return; if (eventTarget?.closest?.('.react-resizable-handle')) return; + if (onSort) { + const headerCell = event.currentTarget as HTMLElement; + const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null; + const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null; + const isInArrow = [upArrow, downArrow].some((el) => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ); + }); + if (isInArrow) return; + } if (cellEditMode && canModifyData && isWritableResultColumn(key, effectiveEditLocator)) { event.preventDefault(); event.stopPropagation(); @@ -2582,20 +2598,6 @@ const DataGrid: React.FC = ({ return; } if (!onSort) return; - const headerCell = event.currentTarget as HTMLElement; - const upArrow = headerCell.querySelector('.ant-table-column-sorter-up') as HTMLElement | null; - const downArrow = headerCell.querySelector('.ant-table-column-sorter-down') as HTMLElement | null; - const isInArrow = [upArrow, downArrow].some((el) => { - if (!el) return false; - const rect = el.getBoundingClientRect(); - return ( - event.clientX >= rect.left && - event.clientX <= rect.right && - event.clientY >= rect.top && - event.clientY <= rect.bottom - ); - }); - if (isInArrow) return; // 仅允许点击上下箭头触发排序,点击字段名或表头其它区域不触发排序。 event.preventDefault(); event.stopPropagation(); diff --git a/frontend/src/components/DataGridPaginationBar.test.tsx b/frontend/src/components/DataGridPaginationBar.test.tsx index 417f9a9f..680b9f4a 100644 --- a/frontend/src/components/DataGridPaginationBar.test.tsx +++ b/frontend/src/components/DataGridPaginationBar.test.tsx @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; -import { resolveDataGridPaginationBoundaryTarget } from './DataGridPaginationBar'; +import DataGridPaginationBar, { resolveDataGridPaginationBoundaryTarget } from './DataGridPaginationBar'; describe('DataGridPaginationBar boundary navigation', () => { it('resolves the first and last page when the total page count is known', () => { @@ -50,4 +52,54 @@ describe('DataGridPaginationBar boundary navigation', () => { canNavigate: false, })).toBeNull(); }); + + it('renders visible first-page and last-page labels instead of icon-only controls', () => { + const translate = (key: string): string => ({ + 'data_grid.pagination.first_page': 'First page', + 'data_grid.pagination.last_page': 'Last page', + }[key] || key); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/data-grid-pagination-first="true"[^>]*>[\s\S]*First page[\s\S]*?<\/button>/); + expect(markup).toMatch(/data-grid-pagination-last="true"[^>]*>[\s\S]*Last page[\s\S]*?<\/button>/); + }); + + it('does not render a total-count action without a real callback', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('data-grid-pagination-total-count="true"'); + }); + }); diff --git a/frontend/src/components/DataGridPaginationBar.tsx b/frontend/src/components/DataGridPaginationBar.tsx index 793417b6..32dc5b5c 100644 --- a/frontend/src/components/DataGridPaginationBar.tsx +++ b/frontend/src/components/DataGridPaginationBar.tsx @@ -65,22 +65,6 @@ export const resolveDataGridPaginationBoundaryTarget = ({ return current < lastPage ? lastPage : null; }; -const findToolbarTotalCountButton = ( - trigger: HTMLElement, - labels: string[], -): HTMLButtonElement | null => { - const root = trigger.closest('.data-grid-root') || trigger.ownerDocument?.body; - if (!root) return null; - const normalizedLabels = labels.map((label) => String(label || '').trim()).filter(Boolean); - const buttons = Array.from(root.querySelectorAll('button')) as HTMLButtonElement[]; - return buttons.find((button) => { - if (button === trigger) return false; - if (button.disabled) return false; - const text = String(button.textContent || '').replace(/\s+/g, ' ').trim(); - return normalizedLabels.some((label) => text === label || text.includes(label)); - }) || null; -}; - const DataGridPaginationBar: React.FC = ({ isV2Ui, pagination, @@ -113,29 +97,17 @@ const DataGridPaginationBar: React.FC = ({ const countTotalLabel = translate('data_grid.toolbar.count_total'); const cancelCountLabel = translate('data_grid.toolbar.cancel_count'); const effectiveTotalCountLoading = totalCountLoading || Boolean(pagination.totalCountLoading); - const shouldShowTotalCountButton = Boolean( - onToggleTotalCount - || manualTotalCountAvailable + const shouldShowTotalCountButton = Boolean(onToggleTotalCount && ( + manualTotalCountAvailable || pagination.totalCountLoading - || pagination.totalKnown === false, - ); - const handleToggleTotalCount = (event: React.MouseEvent) => { - if (onToggleTotalCount) { - onToggleTotalCount(); - return; - } - // Backward-compatible bridge for existing DataGridShell callers: the top toolbar already owns - // the total-count handler, but it can be horizontally scrolled out of view on large toolbars. - // Trigger that existing button so the pagination bar can expose the action without duplicating data-flow state. - const toolbarButton = findToolbarTotalCountButton(event.currentTarget, [countTotalLabel, cancelCountLabel]); - toolbarButton?.click(); - }; + || pagination.totalKnown === false + )); const totalCountButton = shouldShowTotalCountButton ? ( @@ -210,7 +182,9 @@ const DataGridPaginationBar: React.FC = ({ aria-label={firstPageLabel} disabled={firstPageTarget === null} onClick={() => navigateToBoundary(firstPageTarget)} - /> + > + {firstPageLabel} + ); @@ -225,7 +199,9 @@ const DataGridPaginationBar: React.FC = ({ aria-label={lastPageLabel} disabled={lastPageTarget === null} onClick={() => navigateToBoundary(lastPageTarget)} - /> + > + {lastPageLabel} + ); diff --git a/frontend/src/components/DataGridShell.tsx b/frontend/src/components/DataGridShell.tsx index b09e8462..dff1ca89 100644 --- a/frontend/src/components/DataGridShell.tsx +++ b/frontend/src/components/DataGridShell.tsx @@ -448,6 +448,14 @@ const renderDataTableView = () => ( translate={translateDataGrid} /> ); + const handleToggleTotalCount = useCallback(() => { + if (!onRequestTotalCount) return; + if (pagination?.totalCountLoading) { + onCancelTotalCount?.(); + return; + } + onRequestTotalCount(); + }, [onCancelTotalCount, onRequestTotalCount, pagination?.totalCountLoading]); const paginationContent = ( ( 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 {