From 1ac910373584538b0ace24926868f0c1bdb56324 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 10 Jul 2026 21:21:42 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(data-grid):=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E6=95=B0=E6=8D=AE=E7=BC=96=E8=BE=91=E4=BA=8B=E5=8A=A1?= =?UTF-8?q?=E8=AF=AD=E5=8F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/DataGrid.ddl.test.tsx | 20 ++++- frontend/src/components/DataGrid.tsx | 13 +-- frontend/src/components/LogPanel.test.tsx | 22 +++++ frontend/src/components/LogPanel.tsx | 2 +- .../components/dataGridTransactionLog.test.ts | 38 +++++++++ .../src/components/dataGridTransactionLog.ts | 84 +++++++++++++++++++ internal/app/methods_db_create_test.go | 17 +++- internal/app/methods_file.go | 31 +++---- .../app/methods_file_apply_changes_test.go | 55 ++++++++++++ 9 files changed, 258 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/dataGridTransactionLog.test.ts create mode 100644 frontend/src/components/dataGridTransactionLog.ts create mode 100644 internal/app/methods_file_apply_changes_test.go diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 659a1510..39ff6d53 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -859,6 +859,7 @@ describe('DataGrid DDL interactions', () => { ...options, }; }); + storeState.addSqlLog.mockReset(); storeState.addTab.mockReset(); storeState.setActiveContext.mockReset(); storeState.tablePinnedLeftColumns = {}; @@ -1827,7 +1828,15 @@ describe('DataGrid DDL interactions', () => { commitMode: 'auto', autoCommitDelayMs: 3000, }; - backendApp.ApplyChanges.mockResolvedValue({ success: true, message: 'ok' }); + backendApp.ApplyChanges.mockResolvedValue({ + success: true, + message: 'ok', + data: { + deletes: [], + updates: [], + inserts: ["INSERT INTO `users` (`id`, `name`) VALUES (1, 'alpha');"], + }, + }); let renderer: ReactTestRenderer; await act(async () => { @@ -1915,6 +1924,15 @@ describe('DataGrid DDL interactions', () => { deletes: [], locatorStrategy: 'primary-key', }); + expect(storeState.addSqlLog).toHaveBeenLastCalledWith(expect.objectContaining({ + sql: [ + '/* Batch Apply on users */', + 'START TRANSACTION;', + "INSERT INTO `users` (`id`, `name`) VALUES (1, 'alpha');", + 'COMMIT;', + ].join('\n'), + status: 'success', + })); expect(messageApi.success).toHaveBeenCalledWith('自动提交成功'); renderer!.unmount(); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 7dbea4e7..098403a0 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -138,6 +138,7 @@ import DataGridShell from './DataGridShell'; import DataGridModals from './DataGridModals'; import DataGridLegacyCellContextMenu from './DataGridLegacyCellContextMenu'; import DataGridPreviewPanel from './DataGridPreviewPanel'; +import { buildDataGridTransactionLog } from './dataGridTransactionLog'; import { DEFAULT_DATA_EXPORT_FORMAT, DEFAULT_XLSX_ROWS_PER_SHEET, @@ -3050,11 +3051,12 @@ const DataGrid: React.FC = ({ const res = await ApplyChanges(buildRpcConnectionConfig(config) as any, dbName || '', tableName, { inserts, updates, deletes, locatorStrategy: effectiveEditLocator?.strategy } as any); const duration = Date.now() - startTime; - // Construct a pseudo-SQL representation for the log - let logSql = `/* Batch Apply on ${tableName} */\n`; - if (inserts.length > 0) logSql += `INSERT ${inserts.length} rows;\n`; - if (updates.length > 0) logSql += `UPDATE ${updates.length} rows;\n`; - if (deletes.length > 0) logSql += `DELETE ${deletes.length} rows;\n`; + const logSql = buildDataGridTransactionLog({ + dbType, + tableName, + preview: res.data, + committed: res.success, + }); if (res.success) { autoCommitFailedTokenRef.current = -1; @@ -3107,6 +3109,7 @@ const DataGrid: React.FC = ({ normalizeCommitCellValue, shouldCommitColumn, dbName, + dbType, addSqlLog, onReload, translateDataGrid, diff --git a/frontend/src/components/LogPanel.test.tsx b/frontend/src/components/LogPanel.test.tsx index 720852c8..9cb2ba54 100644 --- a/frontend/src/components/LogPanel.test.tsx +++ b/frontend/src/components/LogPanel.test.tsx @@ -145,6 +145,28 @@ describe("LogPanel i18n", () => { expect(renderedText).toContain("OK"); }); + it("keeps detailed transaction statements on separate readable lines", () => { + storeState.sqlLogs = [ + { + id: "transaction-log-1", + timestamp: Date.UTC(2026, 6, 10, 9, 43, 45), + sql: "START TRANSACTION;\nUPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;\nCOMMIT;", + status: "success", + duration: 295, + }, + ]; + + const renderer = renderLogPanel(); + const sqlNodes = renderer.root.findAll((node) => ( + node.type === "div" + && node.props?.style?.fontFamily === "var(--gn-font-mono)" + && node.props?.style?.whiteSpace === "pre-wrap" + )); + + expect(sqlNodes).toHaveLength(1); + expect(textContent(sqlNodes[0])).toContain("UPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;"); + }); + it("renders the current execution error summary inside the embedded log tab", () => { storeState.sqlLogs = [ { diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index 3894c9ac..7eafdfee 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -89,7 +89,7 @@ const LogPanel: React.FC = ({ title: t('log_panel.column.sql_message'), dataIndex: 'sql', render: (text: string, record: any) => ( -
+
{text}
{record.message &&
{record.message}
} {record.affectedRows !== undefined &&
{t('log_panel.affected_rows', { count: record.affectedRows })}
} diff --git a/frontend/src/components/dataGridTransactionLog.test.ts b/frontend/src/components/dataGridTransactionLog.test.ts new file mode 100644 index 00000000..f5345a2a --- /dev/null +++ b/frontend/src/components/dataGridTransactionLog.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildDataGridTransactionLog } from './dataGridTransactionLog'; + +describe('buildDataGridTransactionLog', () => { + it('records the complete committed transaction instead of row-count placeholders', () => { + expect(buildDataGridTransactionLog({ + dbType: 'mysql', + tableName: 'users', + preview: { + deletes: ['DELETE FROM `users` WHERE `id` = 7;'], + updates: ["UPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;"], + inserts: ["INSERT INTO `users` (`id`, `name`) VALUES (9, 'created');"], + }, + committed: true, + })).toBe([ + '/* Batch Apply on users */', + 'START TRANSACTION;', + 'DELETE FROM `users` WHERE `id` = 7;', + "UPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;", + "INSERT INTO `users` (`id`, `name`) VALUES (9, 'created');", + 'COMMIT;', + ].join('\n')); + }); + + it('keeps failed transactions inspectable without claiming that they committed', () => { + const sql = buildDataGridTransactionLog({ + dbType: 'postgres', + tableName: 'users', + preview: { updates: ["UPDATE \"users\" SET \"name\" = 'failed' WHERE \"id\" = 8;"] }, + committed: false, + }); + + expect(sql).toContain('BEGIN;'); + expect(sql).toContain("UPDATE \"users\" SET \"name\" = 'failed' WHERE \"id\" = 8;"); + expect(sql).not.toContain('COMMIT;'); + }); +}); diff --git a/frontend/src/components/dataGridTransactionLog.ts b/frontend/src/components/dataGridTransactionLog.ts new file mode 100644 index 00000000..0ff1e488 --- /dev/null +++ b/frontend/src/components/dataGridTransactionLog.ts @@ -0,0 +1,84 @@ +type ChangePreview = { + deletes?: unknown; + updates?: unknown; + inserts?: unknown; +}; + +type TransactionBoundary = { + begin?: string; + commit?: string; +}; + +const TRANSACTION_BOUNDARIES: Record = { + mysql: { begin: 'START TRANSACTION', commit: 'COMMIT' }, + mariadb: { begin: 'START TRANSACTION', commit: 'COMMIT' }, + diros: { begin: 'START TRANSACTION', commit: 'COMMIT' }, + starrocks: { begin: 'START TRANSACTION', commit: 'COMMIT' }, + sphinx: { begin: 'START TRANSACTION', commit: 'COMMIT' }, + oceanbase: { begin: 'START TRANSACTION', commit: 'COMMIT' }, + sqlserver: { begin: 'BEGIN TRANSACTION', commit: 'COMMIT TRANSACTION' }, + postgres: { begin: 'BEGIN', commit: 'COMMIT' }, + kingbase: { begin: 'BEGIN', commit: 'COMMIT' }, + highgo: { begin: 'BEGIN', commit: 'COMMIT' }, + vastbase: { begin: 'BEGIN', commit: 'COMMIT' }, + opengauss: { begin: 'BEGIN', commit: 'COMMIT' }, + gaussdb: { begin: 'BEGIN', commit: 'COMMIT' }, + sqlite: { begin: 'BEGIN', commit: 'COMMIT' }, + duckdb: { begin: 'BEGIN', commit: 'COMMIT' }, + iris: { begin: 'BEGIN', commit: 'COMMIT' }, + oracle: { commit: 'COMMIT' }, +}; + +const previewStatements = (value: unknown): string[] => ( + Array.isArray(value) + ? value.filter((statement): statement is string => typeof statement === 'string' && statement.trim().length > 0) + .map((statement) => statement.trim()) + : [] +); + +const safeCommentText = (value: string) => value + .replace(/[\r\n]+/g, ' ') + .replace(/\*\//g, '* /') + .trim(); + +export const buildDataGridTransactionLog = ({ + dbType, + tableName, + preview, + committed, +}: { + dbType: string; + tableName: string; + preview?: ChangePreview | null; + committed: boolean; +}): string => { + const statements = [ + ...previewStatements(preview?.deletes), + ...previewStatements(preview?.updates), + ...previewStatements(preview?.inserts), + ]; + const tableLabel = safeCommentText(String(tableName || '')) || 'unknown table'; + const lines = [`/* Batch Apply on ${tableLabel} */`]; + + if (statements.length === 0) { + lines.push('/* Detailed statements are unavailable for this data source. */'); + return lines.join('\n'); + } + + const normalizedDbType = String(dbType || '').trim().toLowerCase(); + const boundary = TRANSACTION_BOUNDARIES[normalizedDbType]; + if (normalizedDbType === 'oracle') { + lines.push('-- Oracle starts the transaction implicitly with the first DML statement.'); + } else if (boundary?.begin) { + lines.push(`${boundary.begin};`); + } + + lines.push(...statements); + if (committed && boundary?.commit) { + lines.push(`${boundary.commit};`); + } else if (!committed) { + lines.push('-- COMMIT was not issued because this batch failed.'); + } + + return lines.join('\n'); +}; diff --git a/internal/app/methods_db_create_test.go b/internal/app/methods_db_create_test.go index c27514cc..b78c7671 100644 --- a/internal/app/methods_db_create_test.go +++ b/internal/app/methods_db_create_test.go @@ -9,8 +9,12 @@ import ( ) type fakeCreateDatabaseDB struct { - connectConfig connection.ConnectionConfig - execQueries []string + connectConfig connection.ConnectionConfig + execQueries []string + applyChanges connection.ChangeSet + previewDeletes []string + previewUpdates []string + previewInserts []string } func (f *fakeCreateDatabaseDB) Connect(config connection.ConnectionConfig) error { @@ -48,8 +52,17 @@ func (f *fakeCreateDatabaseDB) GetForeignKeys(dbName, tableName string) ([]conne func (f *fakeCreateDatabaseDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { return nil, nil } +func (f *fakeCreateDatabaseDB) ApplyChanges(tableName string, changes connection.ChangeSet) error { + f.applyChanges = changes + return nil +} +func (f *fakeCreateDatabaseDB) PreviewChanges(tableName string, changes connection.ChangeSet) (deletes, updates, inserts []string) { + return f.previewDeletes, f.previewUpdates, f.previewInserts +} var _ db.Database = (*fakeCreateDatabaseDB)(nil) +var _ db.BatchApplier = (*fakeCreateDatabaseDB)(nil) +var _ db.ChangePreviewer = (*fakeCreateDatabaseDB)(nil) func TestResolveDDLDBType_SQLServerAliases(t *testing.T) { tests := []connection.ConnectionConfig{ diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index ffd7b8bf..9cea8093 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -2430,11 +2430,12 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName } if applier, ok := dbInst.(db.BatchApplier); ok { + preview := buildChangePreview(dbInst, config, tableName, changes) err := applier.ApplyChanges(tableName, changes) if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} + return connection.QueryResult{Success: false, Message: err.Error(), Data: preview} } - return connection.QueryResult{Success: true, Message: a.appText("file.backend.message.transaction_committed", nil)} + return connection.QueryResult{Success: true, Message: a.appText("file.backend.message.transaction_committed", nil), Data: preview} } return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.batch_commit_unsupported", nil)} @@ -2447,6 +2448,18 @@ type ChangePreview struct { Inserts []string `json:"inserts"` } +func buildChangePreview(dbInst db.Database, config connection.ConnectionConfig, tableName string, changes connection.ChangeSet) ChangePreview { + if previewer, ok := dbInst.(db.ChangePreviewer); ok { + deletes, updates, inserts := previewer.PreviewChanges(tableName, changes) + return ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts} + } + + dbType := resolveDDLDBType(config) + quoter := func(s string) string { return quoteIdentByType(dbType, s) } + deletes, updates, inserts := db.GenerateChangePreview(tableName, changes, quoter) + return ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts} +} + func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult { if err := ensureConnectionAllowsDataEdit(config, "connection.backend.action.preview_result_changes"); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} @@ -2458,19 +2471,7 @@ func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableNa return connection.QueryResult{Success: false, Message: err.Error()} } - var cp ChangePreview - // 优先使用驱动的 PreviewChanges(若实现了 ChangePreviewer 接口) - if previewer, ok := dbInst.(db.ChangePreviewer); ok { - deletes, updates, inserts := previewer.PreviewChanges(tableName, changes) - cp = ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts} - } else { - // 回退到通用生成,使用 quoteIdentByType 处理标识符转义 - dbType := resolveDDLDBType(config) - quoter := func(s string) string { return quoteIdentByType(dbType, s) } - deletes, updates, inserts := db.GenerateChangePreview(tableName, changes, quoter) - cp = ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts} - } - return connection.QueryResult{Success: true, Data: cp} + return connection.QueryResult{Success: true, Data: buildChangePreview(dbInst, config, tableName, changes)} } func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult { diff --git a/internal/app/methods_file_apply_changes_test.go b/internal/app/methods_file_apply_changes_test.go new file mode 100644 index 00000000..4443735b --- /dev/null +++ b/internal/app/methods_file_apply_changes_test.go @@ -0,0 +1,55 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/db" + "GoNavi-Wails/internal/secretstore" +) + +func TestApplyChangesReturnsDetailedSQLPreview(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + t.Cleanup(func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }) + + fakeDB := &fakeCreateDatabaseDB{ + previewDeletes: []string{"DELETE FROM `users` WHERE `id` = 7;"}, + previewUpdates: []string{"UPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;"}, + previewInserts: []string{"INSERT INTO `users` (`id`, `name`) VALUES (9, 'created');"}, + } + newDatabaseFunc = func(dbType string) (db.Database, error) { + return fakeDB, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + result := NewAppWithSecretStore(secretstore.NewUnavailableStore("test")).ApplyChanges( + connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, Database: "main"}, + "main", + "users", + connection.ChangeSet{ + Deletes: []map[string]interface{}{{"id": 7}}, + Updates: []connection.UpdateRow{{Keys: map[string]interface{}{"id": 8}, Values: map[string]interface{}{"name": "new-name"}}}, + Inserts: []map[string]interface{}{{"id": 9, "name": "created"}}, + }, + ) + + if !result.Success { + t.Fatalf("ApplyChanges returned failure: %s", result.Message) + } + preview, ok := result.Data.(ChangePreview) + if !ok { + t.Fatalf("ApplyChanges result data = %#v, want ChangePreview", result.Data) + } + if len(preview.Deletes) != 1 || len(preview.Updates) != 1 || len(preview.Inserts) != 1 { + t.Fatalf("ApplyChanges preview = %#v, want all change statements", preview) + } + if len(fakeDB.applyChanges.Deletes) != 1 || len(fakeDB.applyChanges.Updates) != 1 || len(fakeDB.applyChanges.Inserts) != 1 { + t.Fatalf("ApplyChanges did not send the full change set to the driver: %#v", fakeDB.applyChanges) + } +}