From d2c4160c6d79e39eb06f975c4461d0449766b94d Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:37:34 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8C=82=E8=B5=B7=E4=BA=8B=E5=8A=A1=E5=86=85=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E7=BB=93=E6=9E=9C=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QueryEditor.external-sql-save.test.tsx | 58 ++++++++++++++ frontend/src/components/QueryEditor.tsx | 50 ++++++++++-- .../src/utils/sqlEditorTransaction.test.ts | 16 ++++ frontend/src/utils/sqlEditorTransaction.ts | 23 ++++++ frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/methods_db_multi_test.go | 78 +++++++++++++++++++ internal/app/methods_db_transaction.go | 58 ++++++++++++++ 8 files changed, 281 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index f2585089..aa6592d0 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -100,6 +100,7 @@ const backendApp = vi.hoisted(() => ({ DBQuery: vi.fn(), DBQueryWithCancel: vi.fn(), DBQueryMulti: vi.fn(), + DBQueryMultiInTransaction: vi.fn(), DBQueryMultiTransactional: vi.fn(), DBCommitTransaction: vi.fn(), DBRollbackTransaction: vi.fn(), @@ -673,6 +674,7 @@ describe('QueryEditor external SQL save', () => { backendApp.ExportSQLFile.mockResolvedValue({ success: true }); backendApp.DBQueryWithCancel.mockResolvedValue({ success: true, data: [] }); backendApp.DBQueryMulti.mockResolvedValue({ success: true, data: [] }); + backendApp.DBQueryMultiInTransaction.mockResolvedValue({ success: true, data: [] }); backendApp.DBQueryMultiTransactional.mockResolvedValue({ success: true, data: [] }); backendApp.DBCommitTransaction.mockResolvedValue({ success: true, message: '事务已提交' }); backendApp.DBRollbackTransaction.mockResolvedValue({ success: true, message: '事务已回滚' }); @@ -4760,6 +4762,62 @@ describe('QueryEditor external SQL save', () => { expect(textContent(renderer!.root)).not.toContain('未提交'); }); + it('reuses the pending managed transaction for follow-up read-only SQL in the same tab', async () => { + backendApp.DBQueryMultiTransactional.mockResolvedValueOnce({ + success: true, + transactionId: 'tx-1', + transactionPending: true, + data: [ + { columns: ['affectedRows'], rows: [{ affectedRows: 1 }], statementIndex: 1 }, + ], + }); + backendApp.DBQueryMultiInTransaction.mockResolvedValueOnce({ + success: true, + transactionId: 'tx-1', + transactionPending: true, + data: [ + { columns: ['name'], rows: [{ name: 'new' }], 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(); + }); + + await act(async () => { + renderer.update(); + }); + + await act(async () => { + await findButton(renderer!, '运行').props.onClick(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(backendApp.DBQueryMultiTransactional).toHaveBeenCalledTimes(1); + expect(backendApp.DBQueryMultiInTransaction).toHaveBeenCalledWith( + 'tx-1', + expect.stringContaining('SELECT name FROM users'), + 'query-1', + ); + expect(backendApp.DBQueryMulti).not.toHaveBeenCalled(); + expect(dataGridState.latestProps?.columnNames).toEqual(['name']); + expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ name: 'new' }); + expect(textContent(renderer!.root)).toContain('提交'); + expect(textContent(renderer!.root)).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({ diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 0d813751..fca0ba31 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -6,7 +6,7 @@ import { format } from 'sql-formatter'; import { v4 as uuidv4 } from 'uuid'; import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; -import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; +import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiInTransaction, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; import { GONAVI_ROW_KEY } from './DataGrid'; import { findConnectionMutatingStatements } from '../utils/connectionReadOnly'; import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities'; @@ -25,7 +25,7 @@ import { import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { formatSqlExecutionError, hasLocalizedSqlTimeoutKeyword } from '../utils/sqlErrorSemantics'; -import { shouldUseSqlEditorManagedTransactionForType } from '../utils/sqlEditorTransaction'; +import { canReusePendingSqlEditorTransactionForType, shouldUseSqlEditorManagedTransactionForType } from '../utils/sqlEditorTransaction'; import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; @@ -2874,6 +2874,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }; + const executeSqlEditorMultiQuery = useCallback(( + config: Record, + dbName: string, + sql: string, + queryId: string, + sourceStatements: string[], + ) => { + const pendingTransaction = pendingSqlTransactionRef.current; + if (pendingTransaction && canReusePendingSqlEditorTransactionForType(String(config.type || ''), sourceStatements)) { + return DBQueryMultiInTransaction(pendingTransaction.id, sql, queryId); + } + return DBQueryMulti(buildRpcConnectionConfig(config) as any, dbName, sql, queryId); + }, []); + // 精准重查询单个结果集(提交事务 / 刷新按钮使用),不会重跑整个编辑器 SQL const handleReloadResult = async (resultKey: string, sql: string) => { if (!sql?.trim() || !currentDb) return; @@ -2893,14 +2907,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc try { setLoading(true); - // 使用 DBQueryMulti 保持和首次查询一致的后端路径 + // 保持与首次执行一致的后端路径,必要时复用挂起事务 let queryId: string; try { queryId = await GenerateQueryID(); } catch { queryId = 'reload-' + Date.now(); } - const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, sql, queryId); + const res = await executeSqlEditorMultiQuery( + config, + currentDb, + sql, + queryId, + splitSQLStatements(sql), + ); if (!res?.success) { message.error(translate('query_editor.message.refresh_failed', { error: formatSqlExecutionError(res?.message || translate('common.unknown'), { translate }), @@ -2997,7 +3017,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } catch { queryId = 'query-page-' + Date.now(); } - const res = await DBQueryMulti(buildRpcConnectionConfig(config) as any, currentDb, pageSql, queryId); + const res = await executeSqlEditorMultiQuery( + config, + currentDb, + pageSql, + queryId, + splitSQLStatements(pageSql), + ); if (!res?.success) { message.error(translate('query_editor.message.page_query_failed', { error: formatSqlExecutionError(res?.message || translate('common.unknown'), { translate }), @@ -3417,7 +3443,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (result.applied) anyLimitApplied = true; return { ...plan, executedSql: result.sql }; }); - const fullSQL = executablePlans.map((plan) => plan.executedSql).join(';\n'); + const executableStatements = executablePlans.map((plan) => plan.executedSql); + const fullSQL = executableStatements.join(';\n'); const startTime = Date.now(); let queryId: string; @@ -3429,8 +3456,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } setQueryId(queryId); - const queryExecutor = useManagedTransaction ? DBQueryMultiTransactional : DBQueryMulti; - const res = await queryExecutor(buildRpcConnectionConfig(config) as any, currentDb, fullSQL, queryId); + const res = useManagedTransaction + ? await DBQueryMultiTransactional(buildRpcConnectionConfig(config) as any, currentDb, fullSQL, queryId) + : await executeSqlEditorMultiQuery( + config, + currentDb, + fullSQL, + queryId, + executableStatements, + ); const duration = Date.now() - startTime; addSqlLog({ diff --git a/frontend/src/utils/sqlEditorTransaction.test.ts b/frontend/src/utils/sqlEditorTransaction.test.ts index a0cdb73c..a2a8d348 100644 --- a/frontend/src/utils/sqlEditorTransaction.test.ts +++ b/frontend/src/utils/sqlEditorTransaction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + canReusePendingSqlEditorTransactionForType, resolveSqlEditorOperationKeyword, shouldUseSqlEditorManagedTransaction, shouldUseSqlEditorManagedTransactionForType, @@ -51,4 +52,19 @@ describe('sqlEditorTransaction', () => { 'UPDATE hive.default.orders SET status = \'done\'', ])).toBe(false); }); + + it('reuses a pending managed transaction only for read-only follow-up SQL', () => { + expect(canReusePendingSqlEditorTransactionForType('mysql', [ + 'SELECT * FROM users WHERE id = 1', + ])).toBe(true); + expect(canReusePendingSqlEditorTransactionForType('mysql', [ + 'WITH target AS (SELECT id FROM users) SELECT * FROM target', + ])).toBe(true); + expect(canReusePendingSqlEditorTransactionForType('mysql', [ + 'UPDATE users SET name = "n" WHERE id = 1', + ])).toBe(false); + expect(canReusePendingSqlEditorTransactionForType('mysql', [ + 'COMMIT', + ])).toBe(false); + }); }); diff --git a/frontend/src/utils/sqlEditorTransaction.ts b/frontend/src/utils/sqlEditorTransaction.ts index 69ef5450..5fff33d7 100644 --- a/frontend/src/utils/sqlEditorTransaction.ts +++ b/frontend/src/utils/sqlEditorTransaction.ts @@ -274,3 +274,26 @@ export const shouldUseSqlEditorManagedTransactionForType = ( export const shouldUseSqlEditorManagedTransaction = (statements: string[]): boolean => shouldUseSqlEditorManagedTransactionForType('', statements); + +export const canReusePendingSqlEditorTransactionForType = ( + type: string, + statements: string[], +): boolean => { + if (String(type || '').trim().toLowerCase() === 'trino') { + return false; + } + let hasReadStatement = false; + for (const statement of statements) { + const trimmed = String(statement || '').trim(); + if (!trimmed) continue; + if (isSqlEditorTransactionControlStatement(trimmed)) return false; + if (sqlEditorStatementHasManagedWrite(trimmed)) return false; + const keyword = resolveSqlEditorOperationKeyword(trimmed); + if (!SQL_EDITOR_READ_KEYWORDS.has(keyword)) return false; + hasReadStatement = true; + } + return hasReadStatement; +}; + +export const canReusePendingSqlEditorTransaction = (statements: string[]): boolean => + canReusePendingSqlEditorTransactionForType('', statements); diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 8398a324..cfe1a185 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -60,6 +60,8 @@ export function DBQueryIsolated(arg1:connection.ConnectionConfig,arg2:string,arg export function DBQueryMulti(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function DBQueryMultiInTransaction(arg1:string,arg2:string,arg3:string):Promise; + export function DBQueryMultiTransactional(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 9a45a995..631f3e12 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -110,6 +110,10 @@ export function DBQueryMulti(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['DBQueryMulti'](arg1, arg2, arg3, arg4); } +export function DBQueryMultiInTransaction(arg1, arg2, arg3) { + return window['go']['app']['App']['DBQueryMultiInTransaction'](arg1, arg2, arg3); +} + export function DBQueryMultiTransactional(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['DBQueryMultiTransactional'](arg1, arg2, arg3, arg4); } diff --git a/internal/app/methods_db_multi_test.go b/internal/app/methods_db_multi_test.go index 0b4a3829..2180ab99 100644 --- a/internal/app/methods_db_multi_test.go +++ b/internal/app/methods_db_multi_test.go @@ -687,6 +687,84 @@ func TestDBQueryMultiTransactionalKeepsDMLTransactionOpenUntilCommit(t *testing. } } +func TestDBQueryMultiInTransactionReusesPendingManagedSessionForReadQueries(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + }) + + updateStmt := "UPDATE users SET name = 'new' WHERE id = 1" + readStmt := "SELECT name FROM users WHERE id = 1" + fakeDB := &fakeTransactionalDB{ + fakeBatchWriteDB: fakeBatchWriteDB{ + execAffected: map[string]int64{ + updateStmt: 1, + }, + queryMap: map[string][]map[string]interface{}{ + readStmt: { + {"name": "new"}, + }, + }, + fieldMap: map[string][]string{ + readStmt: {"name"}, + }, + }, + } + newDatabaseFunc = func(dbType string) (db.Database, error) { + return fakeDB, nil + } + + app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")) + config := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root"} + + startResult := app.DBQueryMultiTransactional(config, "main", updateStmt, "tx-query") + if !startResult.Success { + t.Fatalf("expected transactional update success, got failure: %s", startResult.Message) + } + if startResult.TransactionID == "" || !startResult.TransactionPending { + t.Fatalf("expected pending transaction metadata, got id=%q pending=%v", startResult.TransactionID, startResult.TransactionPending) + } + if fakeDB.txSession == nil { + t.Fatal("expected transaction provider session to be opened") + } + if fakeDB.txSession.closed { + t.Fatal("expected transaction session to stay open before follow-up read") + } + + readResult := app.DBQueryMultiInTransaction(startResult.TransactionID, readStmt, "tx-query-read") + if !readResult.Success { + t.Fatalf("expected in-transaction read success, got failure: %s", readResult.Message) + } + if readResult.TransactionID != startResult.TransactionID || !readResult.TransactionPending { + t.Fatalf("expected follow-up read to preserve pending transaction metadata, got id=%q pending=%v", readResult.TransactionID, readResult.TransactionPending) + } + if fakeDB.txSession.queryCalls == 0 { + t.Fatal("expected follow-up read to execute on the pinned transaction session") + } + if fakeDB.txSession.closed { + t.Fatal("expected transaction session to remain open after follow-up read") + } + + resultSets, ok := readResult.Data.([]connection.ResultSetData) + if !ok { + t.Fatalf("expected []connection.ResultSetData from in-transaction read, got %T", readResult.Data) + } + if len(resultSets) != 1 { + t.Fatalf("expected one read result set, got %#v", resultSets) + } + if got := resultSets[0].Rows[0]["name"]; got != "new" { + t.Fatalf("expected in-transaction read to return updated value, got %#v", got) + } + + rollbackResult := app.DBRollbackTransaction(startResult.TransactionID) + if !rollbackResult.Success { + t.Fatalf("expected rollback success after follow-up read, got failure: %s", rollbackResult.Message) + } + if !fakeDB.txSession.closed { + t.Fatal("expected transaction session to close after rollback") + } +} + func TestDBQueryMultiTransactionalUsesImplicitSessionTransactionForOracle(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc t.Cleanup(func() { diff --git a/internal/app/methods_db_transaction.go b/internal/app/methods_db_transaction.go index f7d7c016..a2f099c3 100644 --- a/internal/app/methods_db_transaction.go +++ b/internal/app/methods_db_transaction.go @@ -203,6 +203,64 @@ func (a *App) DBQueryMultiTransactional(config connection.ConnectionConfig, dbNa } } +// DBQueryMultiInTransaction executes follow-up SQL in an existing SQL editor managed transaction. +// The transaction remains open until DBCommitTransaction or DBRollbackTransaction is called. +func (a *App) DBQueryMultiInTransaction(transactionID string, query string, queryID string) connection.QueryResult { + transactionID = strings.TrimSpace(transactionID) + if transactionID == "" { + return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_id_required", nil), QueryID: queryID} + } + if queryID == "" { + queryID = generateQueryID() + } + + a.sqlTransactionMu.Lock() + tx, ok := a.sqlTransactions[transactionID] + a.sqlTransactionMu.Unlock() + if !ok || tx == nil || tx.execer == nil { + return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.transaction_not_found", nil), QueryID: queryID} + } + + runConfig := connection.ConnectionConfig{Type: tx.dbType} + query = sanitizeSQLForPgLike(tx.dbType, query) + statements := splitSQLStatements(query) + + ctx, cancel := newQueryExecutionContext(runConfig) + defer cancel() + + a.queryMu.Lock() + a.runningQueries[queryID] = queryContext{ + cancel: cancel, + started: time.Now(), + } + a.queryMu.Unlock() + defer func() { + a.queryMu.Lock() + delete(a.runningQueries, queryID) + a.queryMu.Unlock() + }() + + resultSets, err := executeManagedSQLTransactionStatements(ctx, tx.execer, runConfig, statements, a.appText) + if err != nil { + logger.Error(err, "DBQueryMultiInTransaction 执行失败:id=%s dbType=%s SQL片段=%q", transactionID, tx.dbType, sqlSnippet(query)) + return connection.QueryResult{ + Success: false, + Message: err.Error(), + QueryID: queryID, + TransactionID: transactionID, + TransactionPending: true, + } + } + + return connection.QueryResult{ + Success: true, + Data: resultSets, + QueryID: queryID, + TransactionID: transactionID, + TransactionPending: true, + } +} + func executeManagedSQLTransactionStatements(ctx context.Context, session db.StatementExecer, runConfig connection.ConnectionConfig, statements []string, text func(string, map[string]any) string) ([]connection.ResultSetData, error) { if text == nil { text = defaultDBBackendText