mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 03:59:41 +08:00
✨ feat(query-editor): 完善片段说明事务状态和结果分页
- SQL 片段新增可编辑语法说明并用于补全详情 - 事务模式改为中文展示并显示未提交变更语句数 - 查询结果支持分页翻页与重新查询全部导出
This commit is contained in:
@@ -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<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
}, [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<DataGridProps> = ({
|
||||
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<DataGridProps> = ({
|
||||
当前页导出 ({queryResultCurrentPageRows.length}条)
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => { void runExport('all'); }}>
|
||||
全部导出 ({mergedDisplayData.length}条)
|
||||
{resultExportAllSql ? '全部导出(重新查询)' : `全部导出 (${mergedDisplayData.length}条)`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5971,7 +5981,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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) => {
|
||||
|
||||
@@ -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(<QueryEditor tab={createTab({ query: sql })} />);
|
||||
});
|
||||
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(<QueryEditor tab={createTab({ query: 'SELECT id FROM users LIMIT 0,500' })} />);
|
||||
});
|
||||
|
||||
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> = [];
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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<QueryEditorResultsPanelProps> = ({
|
||||
onCloseResultTabsToRight,
|
||||
onCloseAllResultTabs,
|
||||
onReloadResult,
|
||||
onResultPageChange,
|
||||
onDiagnoseExecutionError,
|
||||
}) => {
|
||||
const resolvedActiveResultKey = activeResultKey || resultSets[0]?.key || '';
|
||||
@@ -453,15 +457,29 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
|
||||
<DataGrid
|
||||
data={rs.rows}
|
||||
columnNames={rs.columns}
|
||||
loading={loading}
|
||||
loading={loading || rs.page?.loading === true}
|
||||
tableName={rs.tableName}
|
||||
exportScope="queryResult"
|
||||
resultSql={rs.exportSql || rs.sql}
|
||||
resultExportAllSql={rs.page?.exportAllSql}
|
||||
dbName={currentDb}
|
||||
connectionId={currentConnectionId}
|
||||
pkColumns={rs.pkColumns}
|
||||
editLocator={rs.editLocator}
|
||||
onReload={() => 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}
|
||||
/>
|
||||
|
||||
@@ -27,15 +27,15 @@ const QueryEditorTransactionSettings: React.FC<QueryEditorTransactionSettingsPro
|
||||
onAutoCommitDelayMsChange,
|
||||
}) => (
|
||||
<>
|
||||
<Tooltip title="参考 DBeaver:SQL 编辑器执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时先进入 GoNavi 托管事务;Manual Commit 需要手动提交/回滚,Auto-commit 在执行成功后自动 COMMIT。">
|
||||
<Tooltip title="参考 DBeaver:SQL 编辑器执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时先进入 GoNavi 托管事务;手动提交需要手动提交/回滚,自动提交会在执行成功后自动 COMMIT。">
|
||||
<Select
|
||||
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-transaction-mode-select' : undefined}
|
||||
style={isV2Ui ? undefined : { width: 160 }}
|
||||
value={commitMode}
|
||||
onChange={(mode) => onCommitModeChange(mode === 'auto' ? 'auto' : 'manual')}
|
||||
options={[
|
||||
{ label: 'Manual Commit', value: 'manual' },
|
||||
{ label: 'Auto-commit', value: 'auto' },
|
||||
{ label: '手动提交', value: 'manual' },
|
||||
{ label: '自动提交', value: 'auto' },
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type PendingSqlEditorTransaction = {
|
||||
autoCommitDelayMs: number;
|
||||
createdAt: number;
|
||||
autoCommitDueAt?: number | null;
|
||||
statementCount?: number;
|
||||
};
|
||||
|
||||
type QueryEditorTransactionToolbarProps = {
|
||||
@@ -28,11 +29,15 @@ const QueryEditorTransactionToolbar: React.FC<QueryEditorTransactionToolbarProps
|
||||
return null;
|
||||
}
|
||||
|
||||
const statementCount = Math.max(0, Math.floor(Number(transaction.statementCount) || 0));
|
||||
const pendingCountText = statementCount > 0
|
||||
? `,未提交 ${statementCount} 条变更语句`
|
||||
: '';
|
||||
const statusText = transaction.commitMode === 'auto'
|
||||
? autoCommitRemainingSeconds !== null && autoCommitRemainingSeconds > 0
|
||||
? `事务待提交,${autoCommitRemainingSeconds}s 后自动提交`
|
||||
: '事务执行成功,正在自动提交'
|
||||
: '事务待提交';
|
||||
? `事务待提交${pendingCountText},${autoCommitRemainingSeconds}s 后自动提交`
|
||||
: `事务执行成功${pendingCountText},正在自动提交`
|
||||
: `事务待提交${pendingCountText}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -26,6 +26,7 @@ const emptyDraft = (): DraftSnippet => ({
|
||||
prefix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
syntaxHelp: '',
|
||||
body: '',
|
||||
isBuiltin: false,
|
||||
});
|
||||
@@ -122,6 +123,7 @@ export default function SnippetSettingsModal({
|
||||
prefix,
|
||||
name: draft.name.trim(),
|
||||
description: draft.description?.trim() || undefined,
|
||||
syntaxHelp: draft.syntaxHelp?.trim() || undefined,
|
||||
body: draft.body,
|
||||
isBuiltin: draft.isBuiltin,
|
||||
createdAt: draft.createdAt ?? Date.now(),
|
||||
@@ -150,7 +152,7 @@ export default function SnippetSettingsModal({
|
||||
resetBuiltinSqlSnippet(id);
|
||||
const original = BUILTIN_SNIPPET_MAP[id];
|
||||
if (original && selectedId === id) {
|
||||
setDraft({ ...original });
|
||||
setDraft({ ...original, syntaxHelp: original.syntaxHelp || '' });
|
||||
}
|
||||
void message.success('已重置为默认');
|
||||
},
|
||||
@@ -160,7 +162,7 @@ export default function SnippetSettingsModal({
|
||||
const syntaxHelpItems = [
|
||||
{
|
||||
key: 'syntax',
|
||||
label: '片段语法说明',
|
||||
label: '占位符语法参考',
|
||||
children: (
|
||||
<div style={{ fontSize: 12, lineHeight: 1.8, color: mutedColor, fontFamily: 'var(--gn-font-mono)' }}>
|
||||
<div>{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}</div>
|
||||
@@ -351,6 +353,18 @@ export default function SnippetSettingsModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>片段语法说明(可选)</div>
|
||||
<Input.TextArea
|
||||
value={draft.syntaxHelp || ''}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, syntaxHelp: e.target.value }))}
|
||||
placeholder="展示在补全详情中的用法说明,例如占位符含义、参数约定或注意事项"
|
||||
maxLength={1000}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
style={{ fontSize: 12, resize: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ fontSize: 12, color: mutedColor, marginBottom: 4 }}>片段内容</div>
|
||||
<Input.TextArea
|
||||
|
||||
@@ -1131,6 +1131,7 @@ export interface SqlEditorPendingTransactionState {
|
||||
autoCommitDelayMs: number;
|
||||
createdAt: number;
|
||||
autoCommitDueAt?: number | null;
|
||||
statementCount?: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -1397,6 +1398,7 @@ const sanitizeSqlSnippets = (value: unknown): SqlSnippet[] => {
|
||||
prefix,
|
||||
name: toTrimmedString(raw.name, `片段-${index + 1}`) || `片段-${index + 1}`,
|
||||
description: toTrimmedString(raw.description) || undefined,
|
||||
syntaxHelp: toTrimmedString(raw.syntaxHelp) || undefined,
|
||||
body,
|
||||
isBuiltin: raw.isBuiltin === true,
|
||||
createdAt: Number.isFinite(Number(raw.createdAt))
|
||||
|
||||
@@ -469,6 +469,7 @@ export interface SqlSnippet {
|
||||
prefix: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
syntaxHelp?: string;
|
||||
body: string;
|
||||
isBuiltin: boolean;
|
||||
createdAt: number;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolveSqlDialect } from './sqlDialect';
|
||||
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
||||
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
|
||||
|
||||
const getLeadingKeyword = (sql: string): string => {
|
||||
export const getLeadingKeyword = (sql: string): string => {
|
||||
const text = (sql || '').replace(/\r\n/g, '\n');
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
@@ -94,7 +94,7 @@ const getLeadingKeyword = (sql: string): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const splitSqlTail = (sql: string): { main: string; tail: string } => {
|
||||
export const splitSqlTail = (sql: string): { main: string; tail: string } => {
|
||||
const text = (sql || '').replace(/\r\n/g, '\n');
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
@@ -181,7 +181,7 @@ const splitSqlTail = (sql: string): { main: string; tail: string } => {
|
||||
return { main: text.slice(0, mainEnd), tail: text.slice(mainEnd) };
|
||||
};
|
||||
|
||||
const findTopLevelKeyword = (sql: string, keyword: string): number => {
|
||||
export const findTopLevelKeyword = (sql: string, keyword: string): number => {
|
||||
const text = sql;
|
||||
const kw = keyword.toLowerCase();
|
||||
let inSingle = false;
|
||||
|
||||
47
frontend/src/utils/queryResultPagination.test.ts
Normal file
47
frontend/src/utils/queryResultPagination.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildQueryResultPageSql,
|
||||
createInitialQueryResultPagination,
|
||||
resolveQueryResultPaginationTotal,
|
||||
} from './queryResultPagination';
|
||||
|
||||
describe('queryResultPagination', () => {
|
||||
it('treats MySQL LIMIT offset,count as editor pagination and exports the base query', () => {
|
||||
const page = createInitialQueryResultPagination({
|
||||
executedSql: 'SELECT id, name FROM users LIMIT 0,500',
|
||||
exportSql: 'SELECT id, name FROM users LIMIT 0,500',
|
||||
dbType: 'mysql',
|
||||
returnedRowCount: 500,
|
||||
fallbackPageSize: 5000,
|
||||
});
|
||||
|
||||
expect(page).toMatchObject({
|
||||
current: 1,
|
||||
pageSize: 500,
|
||||
total: 1000,
|
||||
totalKnown: false,
|
||||
baseSql: 'SELECT id, name FROM users',
|
||||
exportAllSql: 'SELECT id, name FROM users',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the next page SQL with one lookahead row', () => {
|
||||
expect(buildQueryResultPageSql({
|
||||
baseSql: 'SELECT id FROM users',
|
||||
dbType: 'mysql',
|
||||
page: 2,
|
||||
pageSize: 500,
|
||||
lookahead: true,
|
||||
})).toBe('SELECT * FROM (SELECT id FROM users) AS __gonavi_query_page__ LIMIT 501 OFFSET 500');
|
||||
});
|
||||
|
||||
it('marks the last full lookahead page as an exact total', () => {
|
||||
expect(resolveQueryResultPaginationTotal({
|
||||
current: 2,
|
||||
pageSize: 500,
|
||||
rowCount: 500,
|
||||
hasNext: false,
|
||||
})).toEqual({ total: 1000, totalKnown: true });
|
||||
});
|
||||
});
|
||||
162
frontend/src/utils/queryResultPagination.ts
Normal file
162
frontend/src/utils/queryResultPagination.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { buildPaginatedSelectSQL } from './sql';
|
||||
import { findTopLevelKeyword, getLeadingKeyword, splitSqlTail } from './queryAutoLimit';
|
||||
import { resolveSqlDialect } from './sqlDialect';
|
||||
|
||||
export type QueryResultPaginationState = {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalKnown?: boolean;
|
||||
baseSql: string;
|
||||
exportAllSql?: string;
|
||||
};
|
||||
|
||||
type LimitInfo = {
|
||||
baseSql: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
const normalizePositiveInteger = (value: unknown): number => {
|
||||
const parsed = Math.floor(Number(value));
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
const parseTopLevelLimit = (sql: string): LimitInfo | null => {
|
||||
const { main } = splitSqlTail(sql);
|
||||
const limitPos = findTopLevelKeyword(main, 'limit');
|
||||
if (limitPos < 0) return null;
|
||||
const fromPos = findTopLevelKeyword(main, 'from');
|
||||
if (fromPos >= 0 && limitPos < fromPos) return null;
|
||||
|
||||
const beforeLimit = main.slice(0, limitPos).trimEnd();
|
||||
const limitClause = main.slice(limitPos).trim();
|
||||
const mysqlOffsetLimit = limitClause.match(/^limit\s+(\d+)\s*,\s*(\d+)$/i);
|
||||
if (mysqlOffsetLimit) {
|
||||
const offset = normalizePositiveInteger(mysqlOffsetLimit[1]);
|
||||
const limit = normalizePositiveInteger(mysqlOffsetLimit[2]);
|
||||
return limit > 0 ? { baseSql: beforeLimit, limit, offset } : null;
|
||||
}
|
||||
|
||||
const limitOffset = limitClause.match(/^limit\s+(\d+)\s+offset\s+(\d+)$/i);
|
||||
if (limitOffset) {
|
||||
const limit = normalizePositiveInteger(limitOffset[1]);
|
||||
const offset = normalizePositiveInteger(limitOffset[2]);
|
||||
return limit > 0 ? { baseSql: beforeLimit, limit, offset } : null;
|
||||
}
|
||||
|
||||
const simpleLimit = limitClause.match(/^limit\s+(\d+)$/i);
|
||||
if (simpleLimit) {
|
||||
const limit = normalizePositiveInteger(simpleLimit[1]);
|
||||
return limit > 0 ? { baseSql: beforeLimit, limit, offset: 0 } : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const stripExplicitLimitForExport = (sql: string): string => {
|
||||
const parsed = parseTopLevelLimit(sql);
|
||||
if (parsed?.baseSql) return parsed.baseSql;
|
||||
return splitSqlTail(sql).main.trim();
|
||||
};
|
||||
|
||||
const resolveWrappedBaseSql = (dbType: string, baseSql: string): string => {
|
||||
const normalizedType = String(dbType || '').trim().toLowerCase();
|
||||
const base = baseSql.trim();
|
||||
if (normalizedType === 'oracle' || normalizedType === 'dameng') {
|
||||
return `SELECT * FROM (${base}) "__gonavi_query_page__"`;
|
||||
}
|
||||
return `SELECT * FROM (${base}) AS __gonavi_query_page__`;
|
||||
};
|
||||
|
||||
export const buildQueryResultPageSql = (params: {
|
||||
baseSql: string;
|
||||
dbType: string;
|
||||
driver?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
lookahead?: 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 || '');
|
||||
return buildPaginatedSelectSQL(
|
||||
dialect,
|
||||
resolveWrappedBaseSql(dialect, params.baseSql),
|
||||
'',
|
||||
limit,
|
||||
offset,
|
||||
);
|
||||
};
|
||||
|
||||
export const resolveQueryResultPaginationTotal = (params: {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
rowCount: number;
|
||||
hasNext?: boolean;
|
||||
}): Pick<QueryResultPaginationState, 'total' | 'totalKnown'> => {
|
||||
const current = Math.max(1, Math.floor(Number(params.current) || 1));
|
||||
const pageSize = normalizePositiveInteger(params.pageSize);
|
||||
const rowCount = Math.max(0, Math.floor(Number(params.rowCount) || 0));
|
||||
if (pageSize <= 0) {
|
||||
return { total: rowCount, totalKnown: true };
|
||||
}
|
||||
if (params.hasNext === true) {
|
||||
return { total: (current + 1) * pageSize, totalKnown: false };
|
||||
}
|
||||
if (params.hasNext === false) {
|
||||
return { total: Math.max(0, (current - 1) * pageSize + rowCount), totalKnown: true };
|
||||
}
|
||||
if (rowCount >= pageSize) {
|
||||
return { total: (current + 1) * pageSize, totalKnown: false };
|
||||
}
|
||||
return { total: Math.max(0, (current - 1) * pageSize + rowCount), totalKnown: true };
|
||||
};
|
||||
|
||||
export const createInitialQueryResultPagination = (params: {
|
||||
executedSql: string;
|
||||
exportSql?: string;
|
||||
dbType: string;
|
||||
driver?: string;
|
||||
returnedRowCount: number;
|
||||
fallbackPageSize?: number;
|
||||
}): QueryResultPaginationState | undefined => {
|
||||
const executedSql = String(params.executedSql || '').trim();
|
||||
if (!executedSql || getLeadingKeyword(executedSql) !== 'select') return undefined;
|
||||
|
||||
const explicitLimit = parseTopLevelLimit(executedSql);
|
||||
const mainSql = splitSqlTail(executedSql).main.trim();
|
||||
const fallbackPageSize = normalizePositiveInteger(params.fallbackPageSize);
|
||||
const returnedRowCount = Math.max(0, Math.floor(Number(params.returnedRowCount) || 0));
|
||||
const pageSize = explicitLimit?.limit || fallbackPageSize || returnedRowCount;
|
||||
if (pageSize <= 0) return undefined;
|
||||
|
||||
const current = explicitLimit
|
||||
? Math.max(1, Math.floor(explicitLimit.offset / pageSize) + 1)
|
||||
: 1;
|
||||
if (current <= 1 && returnedRowCount < pageSize) return undefined;
|
||||
|
||||
const baseSql = explicitLimit?.baseSql || mainSql;
|
||||
if (!baseSql) return undefined;
|
||||
|
||||
const exportSql = String(params.exportSql || '').trim();
|
||||
const exportAllSql = exportSql && getLeadingKeyword(exportSql) === 'select'
|
||||
? stripExplicitLimitForExport(exportSql)
|
||||
: stripExplicitLimitForExport(executedSql);
|
||||
const totalState = resolveQueryResultPaginationTotal({
|
||||
current,
|
||||
pageSize,
|
||||
rowCount: returnedRowCount,
|
||||
});
|
||||
|
||||
return {
|
||||
current,
|
||||
pageSize,
|
||||
...totalState,
|
||||
baseSql,
|
||||
exportAllSql,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user