feat(schema): 支持模式编辑删除及按模式导出备份

- 新增 PostgreSQL 系模式重命名与删除能力
- 侧栏模式节点补充右键菜单、编辑弹窗和删除确认
- 导出表结构与备份表数据支持按模式过滤表和视图
- 同步补充 Wails 绑定与前后端定向测试
Close #526
This commit is contained in:
Syngnat
2026-06-14 17:48:29 +08:00
parent f3e11961dc
commit 5f892d29c8
9 changed files with 729 additions and 4 deletions

View File

@@ -182,14 +182,52 @@ func buildCreateSchemaSQL(dbType string, schemaName string) (string, error) {
return fmt.Sprintf("CREATE SCHEMA %s", quoteIdentByType(dbType, schemaName)), nil
}
func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult {
dbType := resolveDDLDBType(config)
func buildRenameSchemaSQL(dbType string, oldSchemaName string, newSchemaName string) (string, error) {
oldSchemaName = strings.TrimSpace(oldSchemaName)
newSchemaName = strings.TrimSpace(newSchemaName)
if oldSchemaName == "" || newSchemaName == "" {
return "", fmt.Errorf("模式名称不能为空")
}
if strings.EqualFold(oldSchemaName, newSchemaName) {
return "", fmt.Errorf("新旧模式名称不能相同")
}
if !isPostgresSchemaDDLDBType(dbType) {
return "", fmt.Errorf("当前数据源(%s暂不支持通过此入口编辑模式", dbType)
}
return fmt.Sprintf(
"ALTER SCHEMA %s RENAME TO %s",
quoteIdentByType(dbType, oldSchemaName),
quoteIdentByType(dbType, newSchemaName),
), nil
}
func buildDropSchemaSQL(dbType string, schemaName string) (string, error) {
schemaName = strings.TrimSpace(schemaName)
if schemaName == "" {
return "", fmt.Errorf("模式名称不能为空")
}
if !isPostgresSchemaDDLDBType(dbType) {
return "", fmt.Errorf("当前数据源(%s暂不支持通过此入口删除模式", dbType)
}
return fmt.Sprintf("DROP SCHEMA %s CASCADE", quoteIdentByType(dbType, schemaName)), nil
}
func resolveSchemaDDLTargetDatabase(config connection.ConnectionConfig, dbName string) (string, error) {
targetDbName := strings.TrimSpace(dbName)
if targetDbName == "" {
targetDbName = strings.TrimSpace(config.Database)
}
if targetDbName == "" {
return connection.QueryResult{Success: false, Message: "目标数据库不能为空"}
return "", fmt.Errorf("目标数据库不能为空")
}
return targetDbName, nil
}
func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult {
dbType := resolveDDLDBType(config)
targetDbName, err := resolveSchemaDDLTargetDatabase(config, dbName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
query, err := buildCreateSchemaSQL(dbType, schemaName)
@@ -210,6 +248,52 @@ func (a *App) CreateSchema(config connection.ConnectionConfig, dbName string, sc
return connection.QueryResult{Success: true, Message: "模式创建成功"}
}
func (a *App) RenameSchema(config connection.ConnectionConfig, dbName string, oldSchemaName string, newSchemaName string) connection.QueryResult {
dbType := resolveDDLDBType(config)
targetDbName, err := resolveSchemaDDLTargetDatabase(config, dbName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
query, err := buildRenameSchemaSQL(dbType, oldSchemaName, newSchemaName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
runConfig := buildRunConfigForDDL(config, dbType, targetDbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(query); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "模式重命名成功"}
}
func (a *App) DropSchema(config connection.ConnectionConfig, dbName string, schemaName string) connection.QueryResult {
dbType := resolveDDLDBType(config)
targetDbName, err := resolveSchemaDDLTargetDatabase(config, dbName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
query, err := buildDropSchemaSQL(dbType, schemaName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
runConfig := buildRunConfigForDDL(config, dbType, targetDbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := dbInst.Exec(query); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "模式删除成功"}
}
func resolveDDLDBType(config connection.ConnectionConfig) string {
dbType := strings.ToLower(strings.TrimSpace(config.Type))
if dbType == "doris" {

View File

@@ -0,0 +1,150 @@
package app
import (
"testing"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/db"
"GoNavi-Wails/internal/secretstore"
)
type fakeSchemaDDLDB struct {
connectConfig connection.ConnectionConfig
execQueries []string
}
func (f *fakeSchemaDDLDB) Connect(config connection.ConnectionConfig) error {
f.connectConfig = config
return nil
}
func (f *fakeSchemaDDLDB) Close() error { return nil }
func (f *fakeSchemaDDLDB) Ping() error { return nil }
func (f *fakeSchemaDDLDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
}
func (f *fakeSchemaDDLDB) Exec(query string) (int64, error) {
f.execQueries = append(f.execQueries, query)
return 0, nil
}
func (f *fakeSchemaDDLDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *fakeSchemaDDLDB) GetTables(dbName string) ([]string, error) {
return nil, nil
}
func (f *fakeSchemaDDLDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *fakeSchemaDDLDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *fakeSchemaDDLDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *fakeSchemaDDLDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *fakeSchemaDDLDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *fakeSchemaDDLDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
var _ db.Database = (*fakeSchemaDDLDB)(nil)
func TestBuildRenameSchemaSQL_PostgresQuotesIdentifiers(t *testing.T) {
got, err := buildRenameSchemaSQL("postgresql", `sales"old`, `sales"new`)
if err != nil {
t.Fatalf("expected postgres rename schema SQL, got error: %v", err)
}
const want = `ALTER SCHEMA "sales""old" RENAME TO "sales""new"`
if got != want {
t.Fatalf("unexpected rename schema SQL, want %q got %q", want, got)
}
}
func TestBuildDropSchemaSQL_PostgresUsesCascade(t *testing.T) {
got, err := buildDropSchemaSQL("postgresql", `sales"ops`)
if err != nil {
t.Fatalf("expected postgres drop schema SQL, got error: %v", err)
}
const want = `DROP SCHEMA "sales""ops" CASCADE`
if got != want {
t.Fatalf("unexpected drop schema SQL, want %q got %q", want, got)
}
}
func TestRenameSchema_CustomPostgresUsesSelectedDatabase(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
fakeDB := &fakeSchemaDDLDB{}
newDatabaseFunc = func(dbType string) (db.Database, error) {
return fakeDB, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.RenameSchema(connection.ConnectionConfig{
Type: "custom",
Driver: "pgx",
Database: "postgres",
}, "tenant_db", "sales", `sales"2026`)
if !result.Success {
t.Fatalf("expected rename schema success, got failure: %s", result.Message)
}
if fakeDB.connectConfig.Database != "tenant_db" {
t.Fatalf("expected rename schema connection to use selected database tenant_db, got %q", fakeDB.connectConfig.Database)
}
if len(fakeDB.execQueries) != 1 {
t.Fatalf("expected one rename schema statement, got %d: %#v", len(fakeDB.execQueries), fakeDB.execQueries)
}
const want = `ALTER SCHEMA "sales" RENAME TO "sales""2026"`
if fakeDB.execQueries[0] != want {
t.Fatalf("unexpected rename schema SQL, want %q got %q", want, fakeDB.execQueries[0])
}
}
func TestDropSchema_CustomPostgresUsesCascade(t *testing.T) {
originalNewDatabaseFunc := newDatabaseFunc
originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc
t.Cleanup(func() {
newDatabaseFunc = originalNewDatabaseFunc
resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc
})
fakeDB := &fakeSchemaDDLDB{}
newDatabaseFunc = func(dbType string) (db.Database, error) {
return fakeDB, nil
}
resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
return raw, nil
}
app := NewAppWithSecretStore(secretstore.NewUnavailableStore("test"))
result := app.DropSchema(connection.ConnectionConfig{
Type: "custom",
Driver: "pgx",
Database: "postgres",
}, "tenant_db", `sales"ops`)
if !result.Success {
t.Fatalf("expected drop schema success, got failure: %s", result.Message)
}
if fakeDB.connectConfig.Database != "tenant_db" {
t.Fatalf("expected drop schema connection to use selected database tenant_db, got %q", fakeDB.connectConfig.Database)
}
if len(fakeDB.execQueries) != 1 {
t.Fatalf("expected one drop schema statement, got %d: %#v", len(fakeDB.execQueries), fakeDB.execQueries)
}
const want = `DROP SCHEMA "sales""ops" CASCADE`
if fakeDB.execQueries[0] != want {
t.Fatalf("unexpected drop schema SQL, want %q got %q", want, fakeDB.execQueries[0])
}
}

View File

@@ -2213,6 +2213,74 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
return connection.QueryResult{Success: true, Message: "导出完成"}
}
func (a *App) ExportSchemaSQL(config connection.ConnectionConfig, dbName string, schemaName string, includeData bool) connection.QueryResult {
safeDbName := strings.TrimSpace(dbName)
safeSchemaName := strings.TrimSpace(schemaName)
if safeDbName == "" {
return connection.QueryResult{Success: false, Message: "数据库名称不能为空"}
}
if safeSchemaName == "" {
return connection.QueryResult{Success: false, Message: "模式名称不能为空"}
}
suffix := "schema"
if includeData {
suffix = "backup"
}
filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: fmt.Sprintf("Export %s.%s (SQL)", safeDbName, safeSchemaName),
DefaultFilename: fmt.Sprintf("%s_%s_%s.sql", safeDbName, safeSchemaName, suffix),
})
if err != nil || filename == "" {
return connection.QueryResult{Success: false, Message: "已取消"}
}
runConfig := normalizeRunConfig(config, dbName)
dbInst, err := a.getDatabase(runConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
tables, err := dbInst.GetTables(dbName)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
viewLookup := listViewNameLookup(dbInst, runConfig, dbName)
filteredTables := filterExportObjectsBySchema(runConfig, dbName, tables, safeSchemaName)
filteredViews := filterExportViewLookupBySchema(runConfig, dbName, viewLookup, safeSchemaName)
objects := buildExportObjectOrder(runConfig, dbName, filteredTables, filteredViews, true)
if len(objects) == 0 {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("未在模式 %s 下获取到可导出的表或视图", safeSchemaName)}
}
f, err := os.Create(filename)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
defer f.Close()
w := bufio.NewWriterSize(f, 1024*1024)
defer w.Flush()
if err := writeSQLHeader(w, runConfig, dbName); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if _, err := w.WriteString(fmt.Sprintf("-- Schema: %s\n\n", safeSchemaName)); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
for _, objectName := range objects {
if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, true, includeData, filteredViews); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
}
if err := writeSQLFooter(w, runConfig); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "导出完成"}
}
type tableDataClearMode string
const (
@@ -2510,6 +2578,56 @@ func buildExportObjectOrder(
return append(tables, views...)
}
func filterExportObjectsBySchema(
config connection.ConnectionConfig,
dbName string,
rawObjects []string,
schemaName string,
) []string {
safeSchemaName := strings.TrimSpace(schemaName)
if safeSchemaName == "" {
return append([]string(nil), rawObjects...)
}
filtered := make([]string, 0, len(rawObjects))
for _, rawName := range rawObjects {
objectName := strings.TrimSpace(rawName)
if objectName == "" {
continue
}
objectSchemaName, _ := normalizeSchemaAndTable(config, dbName, objectName)
if strings.EqualFold(strings.TrimSpace(objectSchemaName), safeSchemaName) {
filtered = append(filtered, objectName)
}
}
return filtered
}
func filterExportViewLookupBySchema(
config connection.ConnectionConfig,
dbName string,
viewLookup map[string]string,
schemaName string,
) map[string]string {
safeSchemaName := strings.TrimSpace(schemaName)
if safeSchemaName == "" {
cloned := make(map[string]string, len(viewLookup))
for key, value := range viewLookup {
cloned[key] = value
}
return cloned
}
filtered := make(map[string]string, len(viewLookup))
for key, objectName := range viewLookup {
objectSchemaName, _ := normalizeSchemaAndTable(config, dbName, objectName)
if strings.EqualFold(strings.TrimSpace(objectSchemaName), safeSchemaName) {
filtered[key] = objectName
}
}
return filtered
}
func mapValuesSorted(values map[string]string) []string {
if len(values) == 0 {
return nil

View File

@@ -431,3 +431,48 @@ func TestDumpTableSQL_PostgresBooleanBackupUsesBooleanLiterals(t *testing.T) {
t.Fatalf("PostgreSQL bool 备份不应输出数字布尔值content=%s", content)
}
}
func TestFilterExportObjectsBySchema_PostgresQualifiedObjectsOnly(t *testing.T) {
got := filterExportObjectsBySchema(
connection.ConnectionConfig{Type: "postgres"},
"app_db",
[]string{"public.users", "sales.orders", "sales.v_orders", "analytics.events"},
"sales",
)
want := []string{"sales.orders", "sales.v_orders"}
if len(got) != len(want) {
t.Fatalf("filtered objects length mismatch, want=%d got=%d (%v)", len(want), len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("filtered objects mismatch at %d, want=%q got=%q", i, want[i], got[i])
}
}
}
func TestFilterExportViewLookupBySchema_PostgresQualifiedViewsOnly(t *testing.T) {
got := filterExportViewLookupBySchema(
connection.ConnectionConfig{Type: "postgres"},
"app_db",
map[string]string{
"public.v_users": "public.v_users",
"sales.v_orders": "sales.v_orders",
"sales.v_summary": "sales.v_summary",
},
"sales",
)
if len(got) != 2 {
t.Fatalf("filtered views length mismatch, want=2 got=%d (%v)", len(got), got)
}
if got["sales.v_orders"] != "sales.v_orders" {
t.Fatalf("expected sales.v_orders to be retained, got=%q", got["sales.v_orders"])
}
if got["sales.v_summary"] != "sales.v_summary" {
t.Fatalf("expected sales.v_summary to be retained, got=%q", got["sales.v_summary"])
}
if _, ok := got["public.v_users"]; ok {
t.Fatalf("expected public.v_users to be filtered out, got=%v", got)
}
}