🐛 fix(transaction): 修复匿名SQL块自动提交

- 区分匿名 BEGIN...END 块与显式事务控制语句
- 统一前后端方言判定并挂起 Oracle、达梦和 SQL Server 块内 DML
- 补充编辑器、事务控制器及 SQL Server 回滚链路回归测试
This commit is contained in:
Syngnat
2026-07-14 12:22:07 +08:00
parent 72423e80a8
commit 1b3137d947
8 changed files with 254 additions and 35 deletions

View File

@@ -135,7 +135,9 @@ const backendApp = vi.hoisted(() => ({
DBQueryMultiInTransaction: vi.fn(),
DBQueryMultiTransactional: vi.fn(),
DBCommitTransaction: vi.fn(),
DBCommitTransactionWithTrigger: vi.fn(),
DBRollbackTransaction: vi.fn(),
DBRollbackTransactionWithTrigger: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
@@ -840,7 +842,9 @@ describe('QueryEditor external SQL save', () => {
backendApp.DBQueryMultiInTransaction.mockResolvedValue({ success: true, data: [] });
backendApp.DBQueryMultiTransactional.mockResolvedValue({ success: true, data: [] });
backendApp.DBCommitTransaction.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBCommitTransactionWithTrigger.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBRollbackTransaction.mockResolvedValue({ success: true, message: '事务已回滚' });
backendApp.DBRollbackTransactionWithTrigger.mockResolvedValue({ success: true, message: '事务已回滚' });
backendApp.DBGetColumns.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
@@ -7536,7 +7540,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-1');
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledWith('tx-1', 'manual');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: "START TRANSACTION;\nUPDATE users SET name = 'new' WHERE id = 1;\nCOMMIT;",
status: 'success',
@@ -7737,7 +7741,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-with-dml');
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledWith('tx-with-dml', 'manual');
});
it('shows the pending statement count for multi-SQL manual transactions', async () => {
@@ -8198,7 +8202,7 @@ describe('QueryEditor external SQL save', () => {
});
expect(textContent(renderer!.root)).toContain('3s 后自动提交');
expect(backendApp.DBCommitTransaction).not.toHaveBeenCalled();
expect(backendApp.DBCommitTransactionWithTrigger).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(3000);
@@ -8206,7 +8210,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-auto');
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledWith('tx-auto', 'auto');
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
@@ -8246,7 +8250,7 @@ describe('QueryEditor external SQL save', () => {
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(textContent(renderer!.root)).toContain('自动提交中');
expect(textContent(renderer!.root)).toContain('提交 (1)');
expect(backendApp.DBCommitTransaction).not.toHaveBeenCalled();
expect(backendApp.DBCommitTransactionWithTrigger).not.toHaveBeenCalled();
await act(async () => {
vi.runOnlyPendingTimers();
@@ -8254,7 +8258,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-auto-now');
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledWith('tx-auto-now', 'auto');
expect(textContent(renderer!.root)).not.toContain('自动提交中');
} finally {
vi.useRealTimers();
@@ -8815,11 +8819,13 @@ describe('QueryEditor external SQL save', () => {
renderer?.unmount();
});
it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => {
it('keeps Oracle anonymous PL/SQL block DML pending for a manual transaction', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({
success: true,
transactionId: 'tx-oracle-block',
transactionPending: true,
data: [{ columns: ['affectedRows'], rows: [{ affectedRows: 1 }] }],
});
const plsql = [
@@ -8843,11 +8849,28 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1');
expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledWith(expect.anything(), 'ORCLPDB1', plsql, 'query-1');
expect(backendApp.DBQueryMulti).not.toHaveBeenCalled();
expect(storeState.sqlEditorPendingTransactions['tab-1']).toMatchObject({
id: 'tx-oracle-block',
dbType: 'oracle',
statements: [plsql],
});
expect(textContent(renderer!.root)).toContain('提交');
expect(textContent(renderer!.root)).toContain('回滚');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: plsql,
status: 'success',
}));
await act(async () => {
await findButton(renderer!, '回滚').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBRollbackTransactionWithTrigger).toHaveBeenCalledWith('tx-oracle-block', 'manual');
renderer?.unmount();
});

View File

@@ -6633,13 +6633,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
setActiveResultKey('');
return;
}
const useManagedTransaction = shouldUseSqlEditorManagedTransactionForType(connCaps.type, sourceStatements);
const useManagedTransaction = shouldUseSqlEditorManagedTransactionForType(normalizedDbType, sourceStatements);
if (useManagedTransaction && pendingSqlTransactionRef.current) {
message.warning(translate('query_editor.transaction.message.pending_managed_transaction'));
return;
}
const managedTransactionStatementCount = sourceStatements
.filter((statement) => shouldUseSqlEditorManagedTransactionForType(connCaps.type, [statement]))
.filter((statement) => shouldUseSqlEditorManagedTransactionForType(normalizedDbType, [statement]))
.length || sourceStatements.length;
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;

View File

@@ -3,6 +3,7 @@ import type { I18nParams } from '../../i18n';
import type { SavedConnection, TabData } from '../../types';
import { findSqlStatementRanges } from '../../utils/sqlStatementSelection';
import {
isSqlEditorTransactionControlStatement,
shouldUseSqlEditorManagedTransaction,
shouldUseSqlEditorManagedTransactionForType,
} from '../../utils/sqlEditorTransaction';
@@ -42,10 +43,6 @@ const splitStatements = (sql: string, dbType = ''): string[] =>
.map((range) => String(range.text || '').trim())
.filter(Boolean);
const hasTransactionControlStatement = (statement: string): boolean =>
/^\s*(begin|commit|rollback|savepoint|release)\b/i.test(statement)
|| /^\s*start\s+transaction\b/i.test(statement);
const buildTabSummary = (
tab: TabData | undefined,
connections: SavedConnection[],
@@ -99,7 +96,7 @@ const buildActiveSqlTabSnapshot = (params: {
{ oceanBaseProtocol: connection?.config?.oceanBaseProtocol },
);
const statements = splitStatements(sql, dbType);
const hasExplicitTransactionControl = statements.some(hasTransactionControlStatement);
const hasExplicitTransactionControl = statements.some(isSqlEditorTransactionControlStatement);
const usesManagedTransaction = shouldUseSqlEditorManagedTransactionForType(dbType, statements);
return {
@@ -150,7 +147,7 @@ const isRelevantSqlEditorTransactionLog = (log: SqlLog): boolean => {
const sql = String(log.sql || '');
const statements = splitStatements(sql);
if (shouldUseSqlEditorManagedTransaction(statements)) return true;
if (statements.some(hasTransactionControlStatement)) return true;
if (statements.some(isSqlEditorTransactionControlStatement)) return true;
return /\b(transaction|commit|rollback)\b/i.test(sql)
|| LOG_TRANSACTION_KEYWORD_PATTERN.test(String(log.message || ''));
};