🐛 fix(data-grid): 补全数据编辑事务语句

This commit is contained in:
Syngnat
2026-07-10 21:21:42 +08:00
parent 69be54c1cf
commit 1ac9103735
9 changed files with 258 additions and 24 deletions

View File

@@ -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();
});

View File

@@ -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<DataGridProps> = ({
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<DataGridProps> = ({
normalizeCommitCellValue,
shouldCommitColumn,
dbName,
dbType,
addSqlLog,
onReload,
translateDataGrid,

View File

@@ -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 = [
{

View File

@@ -89,7 +89,7 @@ const LogPanel: React.FC<LogPanelProps> = ({
title: t('log_panel.column.sql_message'),
dataIndex: 'sql',
render: (text: string, record: any) => (
<div style={{ fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all', fontSize: '12px', lineHeight: '1.45' }}>
<div style={{ fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all', whiteSpace: 'pre-wrap', fontSize: '12px', lineHeight: '1.45' }}>
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>{t('log_panel.affected_rows', { count: record.affectedRows })}</div>}

View File

@@ -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;');
});
});

View File

@@ -0,0 +1,84 @@
type ChangePreview = {
deletes?: unknown;
updates?: unknown;
inserts?: unknown;
};
type TransactionBoundary = {
begin?: string;
commit?: string;
};
const TRANSACTION_BOUNDARIES: Record<string, TransactionBoundary> = {
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');
};

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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)
}
}