From 06583abad953ab758bc5753a6df825649fa979e0 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 11 Jun 2026 14:55:35 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E7=89=87=E6=AE=B5=E8=AF=B4=E6=98=8E=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=92=8C=E7=BB=93=E6=9E=9C=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL 片段新增可编辑语法说明并用于补全详情 - 事务模式改为中文展示并显示未提交变更语句数 - 查询结果支持分页翻页与重新查询全部导出 --- frontend/src/components/DataGrid.tsx | 18 +- .../QueryEditor.external-sql-save.test.tsx | 124 +++++++++++++- frontend/src/components/QueryEditor.tsx | 124 +++++++++++++- .../components/QueryEditorResultsPanel.tsx | 22 ++- .../QueryEditorTransactionSettings.tsx | 6 +- .../QueryEditorTransactionToolbar.tsx | 11 +- .../src/components/SnippetSettingsModal.tsx | 18 +- frontend/src/store.ts | 2 + frontend/src/types.ts | 1 + frontend/src/utils/queryAutoLimit.ts | 6 +- .../src/utils/queryResultPagination.test.ts | 47 +++++ frontend/src/utils/queryResultPagination.ts | 162 ++++++++++++++++++ 12 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 frontend/src/utils/queryResultPagination.test.ts create mode 100644 frontend/src/utils/queryResultPagination.ts diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0e0e58a..dec0811 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -1201,6 +1201,7 @@ interface DataGridProps { objectType?: 'table' | 'view' | 'materialized-view'; exportScope?: 'table' | 'queryResult'; resultSql?: string; + resultExportAllSql?: string; dbName?: string; connectionId?: string; pkColumns?: string[]; @@ -1492,6 +1493,7 @@ const VIRTUAL_EDITING_CELL_STYLE: React.CSSProperties = { const DataGrid: React.FC = ({ data, columnNames, loading, tableName, objectType = 'table', exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, + resultExportAllSql, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, scrollSnapshot, onScrollSnapshotChange, toolbarExtraActions @@ -5910,12 +5912,15 @@ const DataGrid: React.FC = ({ }, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); const queryResultCurrentPageRows = useMemo(() => { + if (isQueryResultExport) { + return mergedDisplayData; + } if (!pagination) { return mergedDisplayData; } const offset = Math.max(0, (pagination.current - 1) * pagination.pageSize); return mergedDisplayData.slice(offset, offset + pagination.pageSize); - }, [mergedDisplayData, pagination]); + }, [isQueryResultExport, mergedDisplayData, pagination]); const exportQueryResultRows = useCallback(async (format: string, scope: QueryResultExportScope) => { if (scope === 'selected') { @@ -5935,8 +5940,13 @@ const DataGrid: React.FC = ({ await exportData(queryResultCurrentPageRows, format); return; } + const exportAllSql = String(resultExportAllSql || '').trim(); + if (exportAllSql && connectionId) { + await exportByQuery(exportAllSql, format, tableName || 'query_result'); + return; + } await exportData(mergedDisplayData, format); - }, [exportData, mergedDisplayData, queryResultCurrentPageRows, rowKeyStr, selectedRowKeys]); + }, [connectionId, exportByQuery, exportData, mergedDisplayData, queryResultCurrentPageRows, resultExportAllSql, rowKeyStr, selectedRowKeys, tableName]); const openQueryResultExportScopeModal = useCallback((format: string) => { let instance: { destroy: () => void } | null = null; @@ -5962,7 +5972,7 @@ const DataGrid: React.FC = ({ 当前页导出 ({queryResultCurrentPageRows.length}条) @@ -5971,7 +5981,7 @@ const DataGrid: React.FC = ({ okButtonProps: { style: { display: 'none' } }, maskClosable: true, }); - }, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, selectedRowKeys.length]); + }, [exportQueryResultRows, mergedDisplayData.length, modal, queryResultCurrentPageRows.length, resultExportAllSql, selectedRowKeys.length]); // Context Menu Export const handleExportSelected = useCallback(async (format: string, record: any) => { diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 7e46c2e..5cf99bf 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -2237,6 +2237,50 @@ describe('QueryEditor external SQL save', () => { expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-with-dml'); }); + it('shows the pending statement count for multi-SQL manual transactions', async () => { + const sql = "UPDATE users SET active = 0 WHERE id = 1; DELETE FROM users WHERE id = 2;"; + backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({ + success: true, + transactionId: 'tx-multi-dml', + transactionPending: true, + data: [ + { columns: ['affectedRows'], rows: [{ affectedRows: 1 }], statementIndex: 1 }, + { columns: ['affectedRows'], rows: [{ affectedRows: 1 }], statementIndex: 2 }, + ], + }); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + editorState.selection = { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: sql.length + 1, + }; + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledWith( + expect.anything(), + 'main', + expect.stringContaining('DELETE FROM users'), + 'query-1', + ); + expect(textContent(renderer!.root)).toContain('事务待提交,未提交 2 条变更语句'); + expect(storeState.sqlEditorPendingTransactions['tab-1']).toMatchObject({ + id: 'tx-multi-dml', + statementCount: 2, + }); + }); + it('keeps SQL editor WITH SELECT on the regular query path', async () => { const sql = 'WITH target AS (SELECT id FROM users WHERE active = 1) SELECT * FROM target'; backendApp.DBQueryMulti.mockResolvedValueOnce({ @@ -2268,6 +2312,65 @@ describe('QueryEditor external SQL save', () => { expect(backendApp.DBQueryMultiTransactional).not.toHaveBeenCalled(); }); + it('adds pagination to limited query results and reloads the selected page only', async () => { + const firstPageRows = Array.from({ length: 500 }, (_item, index) => ({ id: index + 1 })); + const secondPageRows = Array.from({ length: 500 }, (_item, index) => ({ id: index + 501 })); + backendApp.DBQueryMulti + .mockResolvedValueOnce({ + success: true, + data: [ + { columns: ['id'], rows: firstPageRows, statementIndex: 1 }, + ], + }) + .mockResolvedValueOnce({ + success: true, + data: [ + { columns: ['id'], rows: secondPageRows, 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({ + current: 1, + pageSize: 500, + total: 1000, + totalKnown: false, + }); + expect(dataGridState.latestProps?.resultExportAllSql).toBe('SELECT id FROM users'); + + await act(async () => { + await dataGridState.latestProps?.onPageChange?.(2, 500); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMulti).toHaveBeenCalledTimes(2); + const pageSql = String(backendApp.DBQueryMulti.mock.calls[1][2]); + expect(pageSql).toContain('SELECT * FROM (SELECT id FROM users) AS __gonavi_query_page__'); + expect(pageSql).toContain('LIMIT 501 OFFSET 500'); + expect(dataGridState.latestProps?.pagination).toMatchObject({ + current: 2, + pageSize: 500, + total: 1000, + totalKnown: true, + }); + expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ id: 501 }); + }); + 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({ @@ -2378,7 +2481,7 @@ describe('QueryEditor external SQL save', () => { expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalled(); expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); - expect(textContent(renderer!.root)).toContain('事务执行成功,正在自动提交'); + expect(textContent(renderer!.root)).toContain('事务执行成功,未提交 1 条变更语句,正在自动提交'); expect(backendApp.DBCommitTransaction).not.toHaveBeenCalled(); await act(async () => { @@ -2388,7 +2491,7 @@ describe('QueryEditor external SQL save', () => { }); expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-auto-now'); - expect(textContent(renderer!.root)).not.toContain('事务执行成功,正在自动提交'); + expect(textContent(renderer!.root)).not.toContain('事务执行成功,未提交 1 条变更语句,正在自动提交'); } finally { vi.useRealTimers(); } @@ -3617,13 +3720,14 @@ describe('QueryEditor external SQL save', () => { expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-mode-select'); expect(transactionSettingsSource).toContain('gn-v2-query-toolbar-transaction-delay-select'); expect(transactionSettingsSource).toContain('参考 DBeaver'); - expect(transactionSettingsSource).toContain("label: 'Manual Commit'"); - expect(transactionSettingsSource).toContain("label: 'Auto-commit'"); + expect(transactionSettingsSource).toContain("label: '手动提交'"); + expect(transactionSettingsSource).toContain("label: '自动提交'"); expect(transactionSettingsSource).toContain("label: '立即'"); expect(source).toContain('QueryEditorTransactionToolbar'); expect(transactionToolbarSource).toContain("className={isV2Ui ? 'gn-v2-query-transaction-toolbar' : undefined}"); expect(transactionToolbarSource).toContain('事务待提交'); - expect(transactionToolbarSource).toContain('事务执行成功,正在自动提交'); + expect(transactionToolbarSource).toContain('未提交 ${statementCount} 条变更语句'); + expect(transactionToolbarSource).toContain('事务执行成功${pendingCountText},正在自动提交'); expect(transactionToolbarSource).toContain('onFinish'); expect(source).toContain('gn-v2-query-toolbar-action-group'); expect(transactionSettingsSource).toContain('style={isV2Ui ? undefined : { width: 160 }}'); @@ -3653,6 +3757,16 @@ describe('QueryEditor external SQL save', () => { expect(queryToolbarCss).not.toContain('justify-content: flex-end;'); }); + it('keeps custom SQL snippet syntax help editable and uses it in completion details', () => { + const modalSource = readFileSync(new URL('./SnippetSettingsModal.tsx', import.meta.url), 'utf8'); + const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); + + expect(modalSource).toContain('片段语法说明(可选)'); + expect(modalSource).toContain('syntaxHelp'); + expect(modalSource).toContain('占位符语法参考'); + expect(source).toContain('s.syntaxHelp || s.description || s.body'); + }); + it('coalesces editor result splitter dragging through requestAnimationFrame', async () => { const moveListeners: Array<(event: MouseEvent) => void> = []; const upListeners: Array<() => void> = []; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 78198d4..e8dcf5f 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -15,6 +15,11 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; +import { + buildQueryResultPageSql, + createInitialQueryResultPagination, + resolveQueryResultPaginationTotal, +} from '../utils/queryResultPagination'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart } from '../utils/sql'; import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; @@ -3595,7 +3600,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc insertText: s.body, insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, detail: s.name, - documentation: s.description || s.body, + documentation: s.syntaxHelp || s.description || s.body, range, sortText: '04' + s.prefix, })), @@ -3925,6 +3930,107 @@ 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); + if (!target?.page?.baseSql || !currentDb) return; + const conn = connections.find(c => c.id === currentConnectionId); + if (!conn) return; + const safePage = Math.max(1, Math.floor(Number(page) || 1)); + const safePageSize = Math.max(1, Math.floor(Number(pageSize) || target.page.pageSize || 1)); + 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: "" } + }; + const dbType = String(config.type || 'mysql'); + const driver = String((config as any).driver || ''); + const normalizedDbType = String(resolveSqlDialect(dbType, driver, { + oceanBaseProtocol: String((config as any).oceanBaseProtocol || ''), + })).toLowerCase(); + const pageSql = buildQueryResultPageSql({ + baseSql: target.page.baseSql, + dbType: normalizedDbType, + driver, + page: safePage, + pageSize: safePageSize, + lookahead: true, + }); + + try { + setLoading(true); + setResultSets(prev => prev.map(rs => + rs.key === resultKey && rs.page + ? { ...rs, page: { ...rs.page, loading: true } } + : rs + )); + let queryId: string; + try { + queryId = await GenerateQueryID(); + } catch { + queryId = 'query-page-' + Date.now(); + } + const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, pageSql, queryId); + if (!res?.success) { + message.error('翻页失败: ' + formatSqlExecutionError(res?.message || '未知错误')); + return; + } + + const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : []; + const rsData = resultSetDataArray[0]; + if (!rsData) { + message.warning('翻页未返回结果集'); + return; + } + const rawRows = Array.isArray(rsData.rows) ? rsData.rows : []; + const hasNext = rawRows.length > safePageSize; + const rows = rawRows.slice(0, safePageSize); + const rowKeyOffset = (safePage - 1) * safePageSize; + rows.forEach((row: any, i: number) => { + if (row && typeof row === 'object') row[GONAVI_ROW_KEY] = rowKeyOffset + i; + }); + const cols = (rsData.columns && rsData.columns.length > 0) + ? rsData.columns + : (rows.length > 0 ? Object.keys(rows[0]) : target.columns); + const totalState = resolveQueryResultPaginationTotal({ + current: safePage, + pageSize: safePageSize, + rowCount: rows.length, + hasNext, + }); + setResultSets(prev => prev.map(rs => + rs.key === resultKey && rs.page + ? { + ...rs, + rows, + columns: cols, + messages: Array.isArray(rsData.messages) ? rsData.messages : [], + resultType: 'grid', + truncated: false, + page: { + ...rs.page, + current: safePage, + pageSize: safePageSize, + ...totalState, + loading: false, + }, + } + : rs + )); + } catch (err: any) { + message.error('翻页失败: ' + formatSqlExecutionError(err?.message || err || '未知错误')); + } finally { + setLoading(false); + setResultSets(prev => prev.map(rs => + rs.key === resultKey && rs.page?.loading + ? { ...rs, page: { ...rs.page, loading: false } } + : rs + )); + } + }; + const handleRun = async () => { const currentQuery = getCurrentQuery(); if (!currentQuery.trim()) return; @@ -4163,6 +4269,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc message.warning('当前 SQL 编辑器已有未提交事务,请先提交或回滚后再执行新的增删改语句。'); return; } + const managedTransactionStatementCount = sourceStatements + .filter((statement) => shouldUseSqlEditorManagedTransaction([statement])) + .length || sourceStatements.length; const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; const statementPlans: QueryStatementPlan[] = []; @@ -4246,6 +4355,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc commitMode: sqlEditorCommitMode, autoCommitDelayMs: sqlEditorAutoCommitDelayMs, createdAt: Date.now(), + statementCount: managedTransactionStatementCount, }); } @@ -4320,6 +4430,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const tableRef = plan?.tableRef; const editLocator = plan?.editLocator; + const page = createInitialQueryResultPagination({ + executedSql, + exportSql: originalSql, + dbType: normalizedDbType, + driver, + returnedRowCount: rows.length, + fallbackPageSize: maxRows, + }); nextResultSets.push({ key: `result-${idx + 1}`, sql: executedSql, @@ -4333,7 +4451,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc pkColumns: plan?.pkColumns || [], editLocator, readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly, - truncated + truncated, + page, }); } } @@ -5212,6 +5331,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc onCloseResultTabsToRight={closeResultTabsToRight} onCloseAllResultTabs={closeAllResultTabs} onReloadResult={handleReloadResult} + onResultPageChange={handleResultPageChange} onDiagnoseExecutionError={handleDiagnoseExecutionError} /> )} diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 7a710d9..45e151e 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -3,6 +3,7 @@ import { Button, Dropdown, Tabs, Tooltip, type MenuProps } from 'antd'; import { CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons'; import type { EditRowLocator } from '../utils/rowLocator'; +import type { QueryResultPaginationState } from '../utils/queryResultPagination'; import DataGrid from './DataGrid'; export type QueryEditorResultSet = { @@ -21,6 +22,7 @@ export type QueryEditorResultSet = { readOnly: boolean; truncated?: boolean; pkLoading?: boolean; + page?: QueryResultPaginationState & { loading?: boolean }; }; interface QueryEditorResultsPanelProps { @@ -42,6 +44,7 @@ interface QueryEditorResultsPanelProps { onCloseResultTabsToRight: (key: string) => void; onCloseAllResultTabs: () => void; onReloadResult: (key: string, sql: string) => void; + onResultPageChange: (key: string, page: number, pageSize: number) => void; onDiagnoseExecutionError: () => void; } @@ -67,6 +70,7 @@ const QueryEditorResultsPanel: React.FC = ({ onCloseResultTabsToRight, onCloseAllResultTabs, onReloadResult, + onResultPageChange, onDiagnoseExecutionError, }) => { const resolvedActiveResultKey = activeResultKey || resultSets[0]?.key || ''; @@ -453,15 +457,29 @@ const QueryEditorResultsPanel: React.FC = ({ onReloadResult(rs.key, rs.sql)} + onReload={() => { + if (rs.page) { + onResultPageChange(rs.key, rs.page.current, rs.page.pageSize); + return; + } + onReloadResult(rs.key, rs.sql); + }} + pagination={rs.page ? { + current: rs.page.current, + pageSize: rs.page.pageSize, + total: rs.page.total, + totalKnown: rs.page.totalKnown, + } : undefined} + onPageChange={rs.page ? ((page, size) => onResultPageChange(rs.key, page, size)) : undefined} readOnly={rs.readOnly} toolbarExtraActions={resolvedActiveResultKey === rs.key ? toolbarHideButton : null} /> diff --git a/frontend/src/components/QueryEditorTransactionSettings.tsx b/frontend/src/components/QueryEditorTransactionSettings.tsx index 5f4a089..596f1c5 100644 --- a/frontend/src/components/QueryEditorTransactionSettings.tsx +++ b/frontend/src/components/QueryEditorTransactionSettings.tsx @@ -27,15 +27,15 @@ const QueryEditorTransactionSettings: React.FC ( <> - +