mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-15 09:23:08 +08:00
🐛 fix(data-grid): 补全数据编辑事务语句
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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>}
|
||||
|
||||
38
frontend/src/components/dataGridTransactionLog.test.ts
Normal file
38
frontend/src/components/dataGridTransactionLog.test.ts
Normal 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;');
|
||||
});
|
||||
});
|
||||
84
frontend/src/components/dataGridTransactionLog.ts
Normal file
84
frontend/src/components/dataGridTransactionLog.ts
Normal 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');
|
||||
};
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
55
internal/app/methods_file_apply_changes_test.go
Normal file
55
internal/app/methods_file_apply_changes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user