diff --git a/frontend/src/components/Sidebar.locate-toolbar.test.tsx b/frontend/src/components/Sidebar.locate-toolbar.test.tsx index aeeede4..be16d62 100644 --- a/frontend/src/components/Sidebar.locate-toolbar.test.tsx +++ b/frontend/src/components/Sidebar.locate-toolbar.test.tsx @@ -45,6 +45,7 @@ import { V2ConnectionGroupContextMenuView, V2ConnectionContextMenuView, V2DatabaseContextMenuView, + V2SchemaContextMenuView, V2TableContextMenuView, V2TableGroupContextMenuView, formatV2TableContextMenuRows, @@ -1364,6 +1365,24 @@ describe('Sidebar locate toolbar', () => { expect(markup).toContain('新建模式'); }); + it('renders the v2 schema context menu with rename and schema-level export actions', () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-v2-schema-context-menu="true"'); + expect(markup).toContain('sales'); + expect(markup).toContain('SCHEMA'); + expect(markup).toContain('编辑模式'); + expect(markup).toContain('刷新对象树'); + expect(markup).toContain('导出当前模式表结构 · SQL'); + expect(markup).toContain('备份当前模式全部表 · 结构 + 数据'); + expect(markup).toContain('删除模式 · DROP CASCADE'); + }); + it('renders the v2 connection context menu for host rail actions', () => { const markup = renderToStaticMarkup( (null); + const [isRenameSchemaModalOpen, setIsRenameSchemaModalOpen] = useState(false); + const [renameSchemaForm] = Form.useForm(); + const [renameSchemaTarget, setRenameSchemaTarget] = useState(null); const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false); const [renameDbForm] = Form.useForm(); const [renameDbTarget, setRenameDbTarget] = useState(null); @@ -2937,6 +2942,39 @@ const Sidebar: React.FC<{ } }; + const handleExportSchemaSQL = async (node: any, includeData: boolean) => { + const conn = node?.dataRef; + const dbName = String(conn?.dbName || '').trim(); + const schemaName = String(conn?.schemaName || '').trim(); + if (!conn || !dbName || !schemaName) { + message.error('未找到目标模式,无法导出'); + return; + } + const hide = message.loading( + includeData + ? `正在备份模式 ${schemaName} (结构+数据)...` + : `正在导出模式 ${schemaName} 表结构...`, + 0, + ); + try { + const res = await (window as any).go.app.App.ExportSchemaSQL( + buildRpcConnectionConfig(conn.config, { database: dbName }) as any, + dbName, + schemaName, + includeData, + ); + hide(); + if (res.success) { + message.success('导出成功'); + } else if (res.message !== '已取消') { + message.error('导出失败: ' + res.message); + } + } catch (e: any) { + hide(); + message.error('导出失败: ' + (e?.message || String(e))); + } + }; + const handleExportTablesSQL = async (nodes: any[], includeData: boolean) => { if (!nodes || nodes.length === 0) return; const first = nodes[0].dataRef; @@ -3988,6 +4026,89 @@ const Sidebar: React.FC<{ } }; + const openRenameSchemaModal = (node: any) => { + const dialect = getMetadataDialect(node?.dataRef as SavedConnection); + const schemaName = String(node?.dataRef?.schemaName || '').trim(); + if (!isPostgresSchemaDialect(dialect) || !schemaName) { + message.warning('当前节点不支持通过此入口编辑模式'); + return; + } + setRenameSchemaTarget(node); + renameSchemaForm.setFieldsValue({ newName: schemaName }); + setIsRenameSchemaModalOpen(true); + }; + + const handleRenameSchema = async () => { + try { + const values = await renameSchemaForm.validateFields(); + const node = renameSchemaTarget; + const conn = node?.dataRef; + const dbName = String(conn?.dbName || '').trim(); + const oldSchemaName = String(conn?.schemaName || '').trim(); + const newSchemaName = String(values?.newName || '').trim(); + if (!conn || !dbName || !oldSchemaName || !newSchemaName) { + message.error('未找到目标模式,无法编辑'); + return; + } + if (oldSchemaName === newSchemaName) { + message.warning('新旧模式名称相同,无需修改'); + return; + } + + const res = await (window as any).go.app.App.RenameSchema( + buildRpcConnectionConfig(conn.config, { database: dbName }) as any, + dbName, + oldSchemaName, + newSchemaName, + ); + if (res.success) { + message.success('模式重命名成功'); + const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${oldSchemaName || 'default'}`; + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); + await loadTables(getDatabaseNodeRef(conn, dbName)); + setIsRenameSchemaModalOpen(false); + setRenameSchemaTarget(null); + renameSchemaForm.resetFields(); + } else { + message.error('编辑失败: ' + res.message); + } + } catch (e) { + // Validate failed + } + }; + + const handleDeleteSchema = (node: any) => { + const conn = node?.dataRef; + const dbName = String(conn?.dbName || '').trim(); + const schemaName = String(conn?.schemaName || '').trim(); + if (!conn || !dbName || !schemaName) { + message.error('未找到目标模式,无法删除'); + return; + } + Modal.confirm({ + title: '确认删除模式', + content: `确定删除模式 "${schemaName}" 吗?这将删除该模式及其中所有对象,操作不可恢复。`, + okButtonProps: { danger: true }, + onOk: async () => { + const res = await (window as any).go.app.App.DropSchema( + buildRpcConnectionConfig(conn.config, { database: dbName }) as any, + dbName, + schemaName, + ); + if (res.success) { + message.success('模式删除成功'); + const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${schemaName || 'default'}`; + setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); + setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix))); + await loadTables(getDatabaseNodeRef(conn, dbName)); + } else { + message.error('删除失败: ' + res.message); + } + } + }); + }; + const buildRuntimeConfig = (conn: any, overrideDatabase?: string, clearDatabase: boolean = false) => { return buildRpcConnectionConfig(conn.config, { database: resolveSidebarRuntimeDatabase( @@ -5979,6 +6100,40 @@ const Sidebar: React.FC<{ ); }; + const handleV2SchemaContextMenuAction = (node: any, action: V2SchemaContextMenuActionKey) => { + switch (action) { + case 'rename-schema': + openRenameSchemaModal(node); + return; + case 'refresh-schema': + void loadTables(getDatabaseNodeRef(node?.dataRef, String(node?.dataRef?.dbName || '').trim())); + return; + case 'export-schema': + void handleExportSchemaSQL(node, false); + return; + case 'backup-schema-sql': + void handleExportSchemaSQL(node, true); + return; + case 'drop-schema': + handleDeleteSchema(node); + return; + default: + return; + } + }; + + const renderV2SchemaContextMenu = (node: any) => ( + { + setContextMenu(null); + handleV2SchemaContextMenuAction(node, action); + }} + /> + ); + const renderV2ConnectionContextMenu = (node: any) => { const conn = node.dataRef as SavedConnection; const capabilities = getDataSourceCapabilities(conn?.config); @@ -6019,6 +6174,7 @@ const Sidebar: React.FC<{ if (!menu.node) return null; if (menu.kind === 'v2-table') return renderV2TableContextMenu(menu.node); if (menu.kind === 'v2-database') return renderV2DatabaseContextMenu(menu.node); + if (menu.kind === 'v2-schema') return renderV2SchemaContextMenu(menu.node); if (menu.kind === 'v2-table-group') return renderV2TableGroupContextMenu(menu.node); if (menu.kind === 'v2-connection') return renderV2ConnectionContextMenu(menu.node); if (menu.kind === 'v2-connection-group') return renderV2ConnectionGroupContextMenu(menu.node); @@ -6456,6 +6612,48 @@ const Sidebar: React.FC<{ const conn = node.dataRef as SavedConnection; const isRedis = conn?.config?.type === 'redis'; + if (node.type === 'object-group' && node.dataRef?.groupKey === 'schema') { + const dialect = getMetadataDialect(node.dataRef as SavedConnection); + const schemaName = String(node?.dataRef?.schemaName || '').trim(); + if (!isPostgresSchemaDialect(dialect) || !schemaName) { + return []; + } + return [ + { + key: 'rename-schema', + label: '编辑模式', + icon: , + onClick: () => openRenameSchemaModal(node) + }, + { + key: 'refresh-schema', + label: '刷新', + icon: , + onClick: () => void loadTables(getDatabaseNodeRef(node.dataRef, node.dataRef.dbName)) + }, + { + key: 'export-schema', + label: '导出当前模式表结构 (SQL)', + icon: , + onClick: () => void handleExportSchemaSQL(node, false) + }, + { + key: 'backup-schema-sql', + label: '备份当前模式全部表 (结构+数据 SQL)', + icon: , + onClick: () => void handleExportSchemaSQL(node, true) + }, + { type: 'divider' }, + { + key: 'drop-schema', + label: '删除模式', + icon: , + danger: true, + onClick: () => handleDeleteSchema(node) + }, + ]; + } + // 表分组节点的右键菜单 if (node.type === 'object-group' && node.dataRef?.groupKey === 'tables') { const groupData = node.dataRef; // { ...conn, dbName, groupKey } @@ -7765,6 +7963,28 @@ const Sidebar: React.FC<{ }); return; } + if ( + isV2Ui + && node?.type === 'object-group' + && node?.dataRef?.groupKey === 'schema' + && isPostgresSchemaDialect(getMetadataDialect(node.dataRef as SavedConnection)) + && String(node?.dataRef?.schemaName || '').trim() + ) { + const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); + setContextMenu({ + x: position.x, + y: position.y, + sourceX: event.clientX, + sourceY: event.clientY, + items: [], + kind: 'v2-schema', + node, + rootClassName: 'gn-v2-table-context-menu-popup', + overlayStyle: { width: 264, maxWidth: 'calc(100vw - 24px)' }, + maxHeight: position.maxHeight, + }); + return; + } if (isV2Ui && node?.type === 'object-group' && node?.dataRef?.groupKey === 'tables') { const position = resolveSidebarContextMenuPosition(event.clientX, event.clientY); setContextMenu({ @@ -8511,6 +8731,23 @@ const Sidebar: React.FC<{ + { + setIsRenameSchemaModalOpen(false); + setRenameSchemaTarget(null); + renameSchemaForm.resetFields(); + }} + > +
+ + + +
+
+ void; +}> = ({ + dbName, + schemaName, + shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM, + onAction, +}) => { + const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems( + items, + onAction as (action: string) => void, + ); + + return ( +
+ } + title={schemaName} + meta={`${dbName || '当前数据库'} · 模式操作`} + pill="SCHEMA" + /> + +
+
维护
+ {renderItems([ + { action: 'rename-schema', icon: , title: '编辑模式', kbd: 'F2', featured: true }, + { action: 'refresh-schema', icon: , title: '刷新对象树', kbd: primaryShortcut('R', shortcutPlatform) }, + ])} + +
导出与备份
+ {renderItems([ + { action: 'export-schema', icon: , title: '导出当前模式表结构 · SQL' }, + { action: 'backup-schema-sql', icon: , title: '备份当前模式全部表 · 结构 + 数据' }, + ])} + +
+ {renderItems([ + { action: 'drop-schema', icon: , title: '删除模式 · DROP CASCADE', tone: 'danger', kbd: '⌫' }, + ])} +
+
+ ); +}; + const DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM: ShortcutPlatform = 'windows'; const primaryShortcut = ( diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 57d822e..6983eda 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -88,6 +88,8 @@ export function DropDatabase(arg1:connection.ConnectionConfig,arg2:string):Promi export function DropFunction(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function DropSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; + export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; @@ -106,6 +108,8 @@ export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:st export function ExportSQLFile(arg1:string,arg2:string):Promise; +export function ExportSchemaSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:boolean):Promise; + export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; @@ -266,6 +270,8 @@ export function RenameSQLDirectory(arg1:string,arg2:string):Promise; +export function RenameSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; + export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 779c853..72f4c99 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -166,6 +166,10 @@ export function DropFunction(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['DropFunction'](arg1, arg2, arg3, arg4); } +export function DropSchema(arg1, arg2, arg3) { + return window['go']['app']['App']['DropSchema'](arg1, arg2, arg3); +} + export function DropTable(arg1, arg2, arg3) { return window['go']['app']['App']['DropTable'](arg1, arg2, arg3); } @@ -202,6 +206,10 @@ export function ExportSQLFile(arg1, arg2) { return window['go']['app']['App']['ExportSQLFile'](arg1, arg2); } +export function ExportSchemaSQL(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['ExportSchemaSQL'](arg1, arg2, arg3, arg4); +} + export function ExportTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExportTable'](arg1, arg2, arg3, arg4); } @@ -522,6 +530,10 @@ export function RenameSQLFile(arg1, arg2) { return window['go']['app']['App']['RenameSQLFile'](arg1, arg2); } +export function RenameSchema(arg1, arg2, arg3, arg4) { + return window['go']['app']['App']['RenameSchema'](arg1, arg2, arg3, arg4); +} + export function RenameTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4); } diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 8a47f6b..86b4444 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -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" { diff --git a/internal/app/methods_db_schema_test.go b/internal/app/methods_db_schema_test.go new file mode 100644 index 0000000..0ede71c --- /dev/null +++ b/internal/app/methods_db_schema_test.go @@ -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]) + } +} diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 7fb0a03..8773c09 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -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 diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 0d9e709..7aa5176 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -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) + } +}