From d8da8d6abf084a25b6dd6505742ecf934740bb30 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 10 Jun 2026 18:05:46 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(editor):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20SQL=20=E7=BC=96=E8=BE=91=E5=99=A8=20DML=20=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=E6=8F=90=E4=BA=A4=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SQL 编辑器 DML 固定进入托管事务 - 区分 WITH SELECT 和 WITH DML 的事务判定 - 调整提交方式文案并补充前后端回归测试 --- .../QueryEditor.external-sql-save.test.tsx | 78 +++++ frontend/src/components/QueryEditor.tsx | 54 +--- .../src/utils/sqlEditorTransaction.test.ts | 41 +++ frontend/src/utils/sqlEditorTransaction.ts | 246 ++++++++++++++++ internal/app/methods_db_multi_test.go | 40 +++ internal/app/sql_sanitize.go | 274 +++++++++++++++++- internal/app/sql_sanitize_test.go | 15 + 7 files changed, 696 insertions(+), 52 deletions(-) create mode 100644 frontend/src/utils/sqlEditorTransaction.test.ts create mode 100644 frontend/src/utils/sqlEditorTransaction.ts 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 ]} /> - +