feat(query-editor): 完善片段说明事务状态和结果分页

- SQL 片段新增可编辑语法说明并用于补全详情

- 事务模式改为中文展示并显示未提交变更语句数

- 查询结果支持分页翻页与重新查询全部导出
This commit is contained in:
Syngnat
2026-06-11 14:55:35 +08:00
parent ce568362c6
commit 06583abad9
12 changed files with 517 additions and 24 deletions

View File

@@ -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) => {

View File

@@ -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> = [];

View File

@@ -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}
/>
)}

View File

@@ -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}
/>

View File

@@ -27,15 +27,15 @@ const QueryEditorTransactionSettings: React.FC<QueryEditorTransactionSettingsPro
onAutoCommitDelayMsChange,
}) => (
<>
<Tooltip title="参考 DBeaverSQL 编辑器执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时先进入 GoNavi 托管事务;Manual Commit 需要手动提交/回滚Auto-commit 在执行成功后自动 COMMIT。">
<Tooltip title="参考 DBeaverSQL 编辑器执行 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>

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -469,6 +469,7 @@ export interface SqlSnippet {
prefix: string;
name: string;
description?: string;
syntaxHelp?: string;
body: string;
isBuiltin: boolean;
createdAt: number;

View File

@@ -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;

View 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 });
});
});

View 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,
};
};