🐛 fix(query-editor): 修正 SQL 编辑器 DML 事务识别

- 统一前后端 DML 与数据修改 CTE 的受管事务判断

- 保留数据修改 CTE 返回行并补充事务回归测试

- 明确 SQL 编辑器事务提交策略文案
This commit is contained in:
Syngnat
2026-06-10 19:13:54 +08:00
parent cf8f9be8dc
commit 89639e36bc
9 changed files with 206 additions and 37 deletions

View File

@@ -48,6 +48,8 @@ const storeState = vi.hoisted(() => ({
autoCommitDelayMs: 5000,
},
setSqlEditorTransactionOptions: vi.fn(),
sqlEditorPendingTransactions: {} as Record<string, unknown>,
setSqlEditorPendingTransaction: vi.fn(),
shortcutOptions: {
runQuery: {
mac: { enabled: false, combo: '' },
@@ -487,6 +489,15 @@ describe('QueryEditor external SQL save', () => {
storeState.setSqlEditorTransactionOptions.mockImplementation((options: Record<string, unknown>) => {
storeState.sqlEditorTransactionOptions = { ...storeState.sqlEditorTransactionOptions, ...options };
});
storeState.sqlEditorPendingTransactions = {};
storeState.setSqlEditorPendingTransaction.mockReset();
storeState.setSqlEditorPendingTransaction.mockImplementation((tabId: string, transaction: unknown) => {
if (!transaction) {
delete storeState.sqlEditorPendingTransactions[tabId];
return;
}
storeState.sqlEditorPendingTransactions[tabId] = transaction;
});
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.warning.mockReset();
@@ -2257,6 +2268,40 @@ describe('QueryEditor external SQL save', () => {
expect(backendApp.DBQueryMultiTransactional).not.toHaveBeenCalled();
});
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({
success: true,
transactionId: 'tx-write-cte',
transactionPending: true,
data: [
{ columns: ['affectedRows'], rows: [{ affectedRows: 3 }], statementIndex: 1 },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: sql })} />);
});
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 audit_logs'),
'query-1',
);
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(textContent(renderer!.root)).toContain('事务待提交');
});
it('auto commits SQL editor DML transactions after the configured delay', async () => {
vi.useFakeTimers();
storeState.sqlEditorTransactionOptions = {
@@ -3521,9 +3566,9 @@ describe('QueryEditor external SQL save', () => {
expect(source).toContain('gn-v2-query-toolbar-max-rows-select');
expect(source).toContain('gn-v2-query-toolbar-transaction-mode-select');
expect(source).toContain('gn-v2-query-toolbar-transaction-delay-select');
expect(source).toContain('这里仅选择事务执行成功后的提交方式');
expect(source).toContain("label: '提交:手动'");
expect(source).toContain("label: '提交:自动'");
expect(source).toContain('这里仅选择事务执行成功后的 COMMIT 方式');
expect(source).toContain("label: '事务:手动提交'");
expect(source).toContain("label: '事务:自动提交'");
expect(source).toContain('gn-v2-query-toolbar-action-group');
expect(source).toContain('style={isV2Ui ? undefined : { width: 150 }}');
expect(source).toContain('style={isV2Ui ? undefined : { width: 200 }}');
@@ -3538,11 +3583,11 @@ describe('QueryEditor external SQL save', () => {
expect(css).toContain('display: inline-flex !important;');
expect(css).toContain('gap: 6px;');
expect(css).toContain('margin-left: 0 !important;');
expect(css).toContain('max-width: 720px;');
expect(css).toContain('max-width: 760px;');
expect(css).toContain('width: 140px !important;');
expect(css).toContain('width: 166px !important;');
expect(css).toContain('width: 132px !important;');
expect(css).toContain('width: 112px !important;');
expect(css).toContain('width: 142px !important;');
expect(css).toContain('width: 82px !important;');
expect(css).toContain('width: 34px !important;');
expect(css).toContain('@media (max-width: 900px)');

View File

@@ -5328,15 +5328,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
]}
/>
</Tooltip>
<Tooltip title="SQL 编辑器执行 INSERT/UPDATE/DELETE 等 DML 时始终启用事务;这里仅选择事务执行成功后的提交方式。">
<Tooltip title="SQL 编辑器执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时会先开启受管事务;这里仅选择事务执行成功后的 COMMIT 方式。">
<Select
className={isV2Ui ? 'gn-v2-query-toolbar-select gn-v2-query-toolbar-transaction-mode-select' : undefined}
style={isV2Ui ? undefined : { width: 128 }}
style={isV2Ui ? undefined : { width: 150 }}
value={sqlEditorCommitMode}
onChange={(mode) => setSqlEditorTransactionOptions({ commitMode: mode === 'auto' ? 'auto' : 'manual' })}
options={[
{ label: '提交:手动', value: 'manual' },
{ label: '提交:自动', value: 'auto' },
{ label: '事务:手动提交', value: 'manual' },
{ label: '事务:自动提交', value: 'auto' },
]}
/>
</Tooltip>

View File

@@ -27,6 +27,12 @@ describe('sqlEditorTransaction', () => {
])).toBe(false);
});
it('uses managed transactions for data-changing CTEs even when the top-level operation is SELECT', () => {
const sql = 'WITH moved AS (DELETE FROM audit_logs WHERE created_at < NOW() RETURNING id) SELECT * FROM moved';
expect(resolveSqlEditorOperationKeyword(sql)).toBe('select');
expect(shouldUseSqlEditorManagedTransaction([sql])).toBe(true);
});
it('does not wrap user-authored explicit transactions', () => {
expect(shouldUseSqlEditorManagedTransaction([
'BEGIN',

View File

@@ -2,6 +2,11 @@ const SQL_EDITOR_DML_KEYWORDS = new Set(['insert', 'update', 'delete', 'replace'
const SQL_EDITOR_READ_KEYWORDS = new Set(['select', 'with', 'show', 'describe', 'desc', 'explain', 'pragma', 'values']);
const SQL_EDITOR_TRANSACTION_CONTROL_KEYWORDS = new Set(['begin', 'commit', 'rollback', 'savepoint', 'release']);
type SqlEditorWithAnalysis = {
keyword: string;
cteHasManagedWrite: boolean;
};
const isSqlEditorKeywordChar = (char: string | undefined): boolean => !!char && /[A-Za-z0-9_]/.test(char);
const skipSqlEditorTrivia = (text: string, start: number): number => {
@@ -167,8 +172,9 @@ const findTopLevelSqlEditorKeyword = (text: string, start: number, keyword: stri
return -1;
};
const resolveSqlEditorKeywordAfterWith = (text: string, start: number): string => {
const resolveSqlEditorWithAnalysis = (text: string, start: number): SqlEditorWithAnalysis => {
let pos = skipSqlEditorTrivia(text, start);
let cteHasManagedWrite = false;
const recursive = readSqlEditorKeyword(text, pos);
if (recursive.keyword === 'recursive') {
pos = recursive.end;
@@ -177,16 +183,16 @@ const resolveSqlEditorKeywordAfterWith = (text: string, start: number): string =
while (pos < text.length) {
pos = skipSqlEditorTrivia(text, pos);
const identifierEnd = skipSqlEditorIdentifierToken(text, pos);
if (identifierEnd < 0) return '';
if (identifierEnd < 0) return { keyword: '', cteHasManagedWrite };
pos = skipSqlEditorTrivia(text, identifierEnd);
if (text[pos] === '(') {
const columnsEnd = skipBalancedSqlEditorParens(text, pos);
if (columnsEnd < 0) return '';
if (columnsEnd < 0) return { keyword: '', cteHasManagedWrite };
pos = skipSqlEditorTrivia(text, columnsEnd);
}
const asEnd = findTopLevelSqlEditorKeyword(text, pos, 'as');
if (asEnd < 0) return '';
if (asEnd < 0) return { keyword: '', cteHasManagedWrite };
pos = skipSqlEditorTrivia(text, asEnd);
const materialized = readSqlEditorKeyword(text, pos);
if (materialized.keyword === 'not') {
@@ -199,18 +205,23 @@ const resolveSqlEditorKeywordAfterWith = (text: string, start: number): string =
}
pos = skipSqlEditorTrivia(text, pos);
if (text[pos] !== '(') return '';
if (text[pos] !== '(') return { keyword: '', cteHasManagedWrite };
const cteBodyStart = pos + 1;
const cteEnd = skipBalancedSqlEditorParens(text, pos);
if (cteEnd < 0) return '';
if (cteEnd < 0) return { keyword: '', cteHasManagedWrite };
const cteBody = text.slice(cteBodyStart, Math.max(cteBodyStart, cteEnd - 1));
if (sqlEditorStatementHasManagedWrite(cteBody)) {
cteHasManagedWrite = true;
}
pos = skipSqlEditorTrivia(text, cteEnd);
if (text[pos] === ',') {
pos++;
continue;
}
return readSqlEditorKeyword(text, pos).keyword;
return { keyword: readSqlEditorKeyword(text, pos).keyword, cteHasManagedWrite };
}
return '';
return { keyword: '', cteHasManagedWrite };
};
export const resolveSqlEditorOperationKeyword = (statement: string): string => {
@@ -219,7 +230,17 @@ export const resolveSqlEditorOperationKeyword = (statement: string): string => {
if (leading.keyword !== 'with') {
return leading.keyword;
}
return resolveSqlEditorKeywordAfterWith(text, leading.end) || leading.keyword;
return resolveSqlEditorWithAnalysis(text, leading.end).keyword || leading.keyword;
};
const sqlEditorStatementHasManagedWrite = (statement: string): boolean => {
const text = String(statement || '');
const leading = readSqlEditorKeyword(text, 0);
if (leading.keyword === 'with') {
const analysis = resolveSqlEditorWithAnalysis(text, leading.end);
return analysis.cteHasManagedWrite || SQL_EDITOR_DML_KEYWORDS.has(analysis.keyword);
}
return SQL_EDITOR_DML_KEYWORDS.has(leading.keyword);
};
const isSqlEditorTransactionControlStatement = (statement: string): boolean => {
@@ -234,12 +255,12 @@ export const shouldUseSqlEditorManagedTransaction = (statements: string[]): bool
const trimmed = String(statement || '').trim();
if (!trimmed) continue;
if (isSqlEditorTransactionControlStatement(trimmed)) return false;
const keyword = resolveSqlEditorOperationKeyword(trimmed);
if (SQL_EDITOR_READ_KEYWORDS.has(keyword)) continue;
if (SQL_EDITOR_DML_KEYWORDS.has(keyword)) {
if (sqlEditorStatementHasManagedWrite(trimmed)) {
hasManagedWrite = true;
continue;
}
const keyword = resolveSqlEditorOperationKeyword(trimmed);
if (SQL_EDITOR_READ_KEYWORDS.has(keyword)) continue;
return false;
}
return hasManagedWrite;

View File

@@ -4810,7 +4810,7 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-actions {
body[data-ui-version="v2"] .gn-v2-query-toolbar-selects {
flex: 0 1 auto !important;
flex-wrap: nowrap;
max-width: 720px;
max-width: 760px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-actions {
@@ -4840,8 +4840,8 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-max-rows-select {
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-mode-select {
width: 112px !important;
flex: 0 0 112px !important;
width: 142px !important;
flex: 0 0 142px !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-transaction-delay-select {