diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 5547a5f..a21a02b 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -2182,6 +2182,81 @@ describe('QueryEditor external SQL save', () => { expect(textContent(renderer!.root)).not.toContain('事务待提交'); }); + it('runs SQL editor WITH DML through a pending managed transaction', async () => { + const sql = 'WITH target AS (SELECT id FROM users WHERE active = 1) UPDATE users SET synced = 1 WHERE id IN (SELECT id FROM target)'; + backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({ + success: true, + transactionId: 'tx-with-dml', + transactionPending: true, + data: [ + { columns: ['affectedRows'], rows: [{ affectedRows: 2 }], 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(backendApp.DBQueryMultiTransactional).toHaveBeenCalledWith( + expect.anything(), + 'main', + expect.stringContaining('WITH target AS'), + 'query-1', + ); + expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); + expect(textContent(renderer!.root)).toContain('事务待提交'); + + await act(async () => { + await findExactButton(renderer!, '提交').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-with-dml'); + }); + + 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({ + success: true, + data: [ + { columns: ['id'], rows: [{ id: 1 }], 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(backendApp.DBQueryMulti).toHaveBeenCalledWith( + expect.anything(), + 'main', + expect.stringContaining('WITH target AS'), + 'query-1', + ); + expect(backendApp.DBQueryMultiTransactional).not.toHaveBeenCalled(); + }); + it('auto commits SQL editor DML transactions after the configured delay', async () => { vi.useFakeTimers(); storeState.sqlEditorTransactionOptions = { @@ -3446,6 +3521,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('gn-v2-query-toolbar-action-group'); expect(source).toContain('style={isV2Ui ? undefined : { width: 150 }}'); expect(source).toContain('style={isV2Ui ? undefined : { width: 200 }}'); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 236f9e1..ac28ab7 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -18,6 +18,7 @@ import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart } from '../utils/sql'; import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; +import { shouldUseSqlEditorManagedTransaction } from '../utils/sqlEditorTransaction'; import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; @@ -750,9 +751,6 @@ const areSqlStatementListsEqual = (left: string[], right: string[]): boolean => && left.every((statement, index) => normalizeExecutedSqlKey(statement) === normalizeExecutedSqlKey(right[index])) ); -const SQL_EDITOR_DML_KEYWORDS = new Set(['insert', 'update', 'delete', 'replace', 'merge', 'upsert']); -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']); const SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS = [ { value: 3000, label: '3 秒' }, { value: 5000, label: '5 秒' }, @@ -760,50 +758,6 @@ const SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS = [ { value: 30000, label: '30 秒' }, ]; -const resolveLeadingSqlKeyword = (statement: string): string => { - let text = String(statement || '').trim(); - while (text) { - if (text.startsWith('--') || text.startsWith('#')) { - const lineBreak = text.indexOf('\n'); - if (lineBreak < 0) return ''; - text = text.slice(lineBreak + 1).trimStart(); - continue; - } - if (text.startsWith('/*')) { - const blockEnd = text.indexOf('*/'); - if (blockEnd < 0) return ''; - text = text.slice(blockEnd + 2).trimStart(); - continue; - } - break; - } - const match = text.match(/^([A-Za-z0-9_]+)/); - return match?.[1]?.toLowerCase() || ''; -}; - -const isSqlEditorTransactionControlStatement = (statement: string): boolean => { - const keyword = resolveLeadingSqlKeyword(statement); - if (SQL_EDITOR_TRANSACTION_CONTROL_KEYWORDS.has(keyword)) return true; - return keyword === 'start' && /\btransaction\b/i.test(statement); -}; - -const shouldUseSqlEditorManagedTransaction = (statements: string[]): boolean => { - let hasManagedWrite = false; - for (const statement of statements) { - const trimmed = String(statement || '').trim(); - if (!trimmed) continue; - if (isSqlEditorTransactionControlStatement(trimmed)) return false; - const keyword = resolveLeadingSqlKeyword(trimmed); - if (SQL_EDITOR_READ_KEYWORDS.has(keyword)) continue; - if (SQL_EDITOR_DML_KEYWORDS.has(keyword)) { - hasManagedWrite = true; - continue; - } - return false; - } - return hasManagedWrite; -}; - type PendingSqlEditorTransaction = { id: string; commitMode: 'manual' | 'auto'; @@ -5369,15 +5323,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ]} /> - +