mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-10 16:39:41 +08:00
✨ feat(data-grid): 增强数据表编辑与展示体验
- 新增变更预览能力,支持在提交前查看删除、更新和新增对应的 SQL 语句 - 增加表格密度配置,统一控制默认列宽、行高、字号与单元格内边距 - 优化 DataGrid 编辑状态展示,区分新增、修改和删除行列的视觉反馈 - 调整导出入口与 Wails 前端绑定,补齐变更预览相关调用与测试覆盖
This commit is contained in:
@@ -1012,6 +1012,35 @@ func (a *App) ApplyChanges(config connection.ConnectionConfig, dbName, tableName
|
||||
return connection.QueryResult{Success: false, Message: "当前数据库类型不支持批量提交"}
|
||||
}
|
||||
|
||||
// ChangePreview 变更预览结果
|
||||
type ChangePreview struct {
|
||||
Deletes []string `json:"deletes"`
|
||||
Updates []string `json:"updates"`
|
||||
Inserts []string `json:"inserts"`
|
||||
}
|
||||
|
||||
func (a *App) PreviewChanges(config connection.ConnectionConfig, dbName, tableName string, changes connection.ChangeSet) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
if err != nil {
|
||||
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 处理标识符转义
|
||||
quoter := func(s string) string { return quoteIdentByType(runConfig.Type, s) }
|
||||
deletes, updates, inserts := db.GenerateChangePreview(tableName, changes, quoter)
|
||||
cp = ChangePreview{Deletes: deletes, Updates: updates, Inserts: inserts}
|
||||
}
|
||||
return connection.QueryResult{Success: true, Data: cp}
|
||||
}
|
||||
|
||||
func (a *App) ExportTable(config connection.ConnectionConfig, dbName string, tableName string, format string) connection.QueryResult {
|
||||
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: fmt.Sprintf("Export %s", tableName),
|
||||
|
||||
135
internal/db/change_preview.go
Normal file
135
internal/db/change_preview.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
// GenerateChangePreview 将 ChangeSet 转为可读 SQL 语句(不执行)。
|
||||
// quoteIdent 用于引用列名/表名(MySQL: backtick, PostgreSQL: double quote)。
|
||||
func GenerateChangePreview(tableName string, changes connection.ChangeSet, quoteIdent func(string) string) (deletes, updates, inserts []string) {
|
||||
qt := quoteIdent
|
||||
|
||||
// Deletes
|
||||
for _, pk := range changes.Deletes {
|
||||
var conds []string
|
||||
for _, k := range sortedKeys(pk) {
|
||||
v := pk[k]
|
||||
conds = append(conds, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v)))
|
||||
}
|
||||
if len(conds) > 0 {
|
||||
deletes = append(deletes, fmt.Sprintf("DELETE FROM %s WHERE %s;", qt(tableName), strings.Join(conds, " AND ")))
|
||||
}
|
||||
}
|
||||
|
||||
// Updates
|
||||
for _, row := range changes.Updates {
|
||||
var sets []string
|
||||
for _, k := range sortedKeys(row.Values) {
|
||||
v := row.Values[k]
|
||||
sets = append(sets, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v)))
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
continue
|
||||
}
|
||||
var conds []string
|
||||
for _, k := range sortedKeys(row.Keys) {
|
||||
v := row.Keys[k]
|
||||
conds = append(conds, fmt.Sprintf("%s = %s", qt(k), formatLiteral(v)))
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, fmt.Sprintf("UPDATE %s SET %s WHERE %s;", qt(tableName), strings.Join(sets, ", "), strings.Join(conds, " AND ")))
|
||||
}
|
||||
|
||||
// Inserts
|
||||
for _, row := range changes.Inserts {
|
||||
var cols []string
|
||||
var vals []string
|
||||
for _, k := range sortedKeys(row) {
|
||||
v := row[k]
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
cols = append(cols, qt(k))
|
||||
vals = append(vals, formatLiteral(v))
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
continue
|
||||
}
|
||||
inserts = append(inserts, fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", qt(tableName), strings.Join(cols, ", "), strings.Join(vals, ", ")))
|
||||
}
|
||||
|
||||
return deletes, updates, inserts
|
||||
}
|
||||
|
||||
// sortedKeys 返回 map 的键排序切片,保证输出确定性。
|
||||
func sortedKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// formatLiteral 将 Go 值转为 SQL 字面量字符串。
|
||||
func formatLiteral(v interface{}) string {
|
||||
if v == nil {
|
||||
return "NULL"
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
escaped := strings.ReplaceAll(t, "\\", "\\\\")
|
||||
escaped = strings.ReplaceAll(escaped, "'", "\\'")
|
||||
return fmt.Sprintf("'%s'", escaped)
|
||||
case float64:
|
||||
return formatNumber(t)
|
||||
case float32:
|
||||
return formatNumber(float64(t))
|
||||
case int:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case int64:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case int32:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case int16:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case int8:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case uint64:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case uint32:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case uint16:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case uint8:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case uint:
|
||||
return fmt.Sprintf("%d", t)
|
||||
case time.Time:
|
||||
return fmt.Sprintf("'%s'", t.Format("2006-01-02 15:04:05"))
|
||||
case bool:
|
||||
if t {
|
||||
return "TRUE"
|
||||
}
|
||||
return "FALSE"
|
||||
case []byte:
|
||||
return formatLiteral(string(t))
|
||||
default:
|
||||
escaped := strings.ReplaceAll(fmt.Sprintf("%v", t), "'", "\\'")
|
||||
return fmt.Sprintf("'%s'", escaped)
|
||||
}
|
||||
}
|
||||
|
||||
func formatNumber(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return fmt.Sprintf("%d", int64(f))
|
||||
}
|
||||
return fmt.Sprintf("%v", f)
|
||||
}
|
||||
113
internal/db/change_preview_test.go
Normal file
113
internal/db/change_preview_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestGenerateChangePreview_Inserts(t *testing.T) {
|
||||
changes := connection.ChangeSet{
|
||||
Inserts: []map[string]interface{}{
|
||||
{"name": "alice", "age": float64(30)},
|
||||
{"name": "bob", "age": nil},
|
||||
},
|
||||
}
|
||||
deletes, updates, inserts := GenerateChangePreview("users", changes, mysqlQuote)
|
||||
if len(inserts) != 2 {
|
||||
t.Fatalf("expected 2 inserts, got %d", len(inserts))
|
||||
}
|
||||
expected1 := "INSERT INTO `users` (`age`, `name`) VALUES (30, 'alice');"
|
||||
expected2 := "INSERT INTO `users` (`name`) VALUES ('bob');"
|
||||
if inserts[0] != expected1 {
|
||||
t.Errorf("insert[0]: got %s\nwant %s", inserts[0], expected1)
|
||||
}
|
||||
if inserts[1] != expected2 {
|
||||
t.Errorf("insert[1]: got %s\nwant %s", inserts[1], expected2)
|
||||
}
|
||||
if len(deletes) != 0 || len(updates) != 0 {
|
||||
t.Errorf("expected empty deletes/updates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateChangePreview_Deletes(t *testing.T) {
|
||||
changes := connection.ChangeSet{
|
||||
Deletes: []map[string]interface{}{
|
||||
{"id": float64(1), "name": "alice"},
|
||||
{"id": float64(2)},
|
||||
},
|
||||
}
|
||||
deletes, updates, inserts := GenerateChangePreview("users", changes, mysqlQuote)
|
||||
if len(deletes) != 2 {
|
||||
t.Fatalf("expected 2 deletes, got %d", len(deletes))
|
||||
}
|
||||
expected1 := "DELETE FROM `users` WHERE `id` = 1 AND `name` = 'alice';"
|
||||
if deletes[0] != expected1 {
|
||||
t.Errorf("delete[0]: got %s\nwant %s", deletes[0], expected1)
|
||||
}
|
||||
if len(updates) != 0 || len(inserts) != 0 {
|
||||
t.Errorf("expected empty updates/inserts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateChangePreview_Updates(t *testing.T) {
|
||||
changes := connection.ChangeSet{
|
||||
Updates: []connection.UpdateRow{
|
||||
{
|
||||
Keys: map[string]interface{}{"id": float64(1)},
|
||||
Values: map[string]interface{}{"name": "charlie", "age": float64(25)},
|
||||
},
|
||||
},
|
||||
}
|
||||
deletes, updates, inserts := GenerateChangePreview("users", changes, mysqlQuote)
|
||||
if len(updates) != 1 {
|
||||
t.Fatalf("expected 1 update, got %d", len(updates))
|
||||
}
|
||||
// SET clause column order is map-iteration-based, so check substring presence
|
||||
if !strings.Contains(updates[0], "UPDATE `users` SET") {
|
||||
t.Errorf("update: missing UPDATE clause: %s", updates[0])
|
||||
}
|
||||
if !strings.Contains(updates[0], "WHERE `id` = 1") {
|
||||
t.Errorf("update: missing WHERE clause: %s", updates[0])
|
||||
}
|
||||
if !strings.Contains(updates[0], "`name` = 'charlie'") {
|
||||
t.Errorf("update: missing name set: %s", updates[0])
|
||||
}
|
||||
if !strings.Contains(updates[0], "`age` = 25") {
|
||||
t.Errorf("update: missing age set: %s", updates[0])
|
||||
}
|
||||
if len(deletes) != 0 || len(inserts) != 0 {
|
||||
t.Errorf("expected empty deletes/inserts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateChangePreview_EmptyChanges(t *testing.T) {
|
||||
deletes, updates, inserts := GenerateChangePreview("t", connection.ChangeSet{}, mysqlQuote)
|
||||
if len(deletes) != 0 || len(updates) != 0 || len(inserts) != 0 {
|
||||
t.Error("expected all empty for empty changeset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLiteral(t *testing.T) {
|
||||
cases := []struct {
|
||||
val interface{}
|
||||
expected string
|
||||
}{
|
||||
{nil, "NULL"},
|
||||
{"hello", "'hello'"},
|
||||
{"it's a test", "'it\\'s a test'"},
|
||||
{float64(42), "42"},
|
||||
{int64(-1), "-1"},
|
||||
{true, "TRUE"},
|
||||
{false, "FALSE"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := formatLiteral(c.val)
|
||||
if got != c.expected {
|
||||
t.Errorf("formatLiteral(%v): got %s, want %s", c.val, got, c.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mysqlQuote(s string) string { return "`" + s + "`" }
|
||||
@@ -65,6 +65,12 @@ type BatchApplier interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
}
|
||||
|
||||
// ChangePreviewer 是可选的变更预览接口。
|
||||
// 驱动可实现此接口提供自定义 SQL 预览格式;若未实现,调用方回退到 GenerateChangePreview。
|
||||
type ChangePreviewer interface {
|
||||
PreviewChanges(tableName string, changes connection.ChangeSet) (deletes, updates, inserts []string)
|
||||
}
|
||||
|
||||
func requireSingleRowAffected(result sql.Result, action string) error {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user