mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-25 02:00:09 +08:00
✨ feat(schema): 支持模式编辑删除及按模式导出备份
- 新增 PostgreSQL 系模式重命名与删除能力 - 侧栏模式节点补充右键菜单、编辑弹窗和删除确认 - 导出表结构与备份表数据支持按模式过滤表和视图 - 同步补充 Wails 绑定与前后端定向测试 Close #526
This commit is contained in:
@@ -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" {
|
||||
|
||||
150
internal/app/methods_db_schema_test.go
Normal file
150
internal/app/methods_db_schema_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user