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

@@ -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(
<V2SchemaContextMenuView
dbName="app_db"
schemaName="sales"
/>,
);
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(
<V2ConnectionContextMenuView

View File

@@ -118,11 +118,13 @@ import {
V2DatabaseContextMenuView,
V2ConnectionGroupContextMenuView,
V2ConnectionContextMenuView,
V2SchemaContextMenuView,
V2TableContextMenuView,
V2TableGroupContextMenuView,
type V2DatabaseContextMenuActionKey,
type V2ConnectionGroupContextMenuActionKey,
type V2ConnectionContextMenuActionKey,
type V2SchemaContextMenuActionKey,
type V2TableContextMenuActionKey,
type V2TableContextMenuStats,
type V2TableGroupContextMenuActionKey,
@@ -206,7 +208,7 @@ type SidebarContextMenuState = {
sourceX?: number;
sourceY?: number;
items: MenuProps['items'];
kind?: 'v2-table' | 'v2-database' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group';
kind?: 'v2-table' | 'v2-database' | 'v2-schema' | 'v2-table-group' | 'v2-connection' | 'v2-connection-group';
node?: any;
rootClassName?: string;
overlayStyle?: React.CSSProperties;
@@ -608,6 +610,9 @@ const Sidebar: React.FC<{
const [isCreateSchemaModalOpen, setIsCreateSchemaModalOpen] = useState(false);
const [createSchemaForm] = Form.useForm();
const [createSchemaTarget, setCreateSchemaTarget] = useState<any>(null);
const [isRenameSchemaModalOpen, setIsRenameSchemaModalOpen] = useState(false);
const [renameSchemaForm] = Form.useForm();
const [renameSchemaTarget, setRenameSchemaTarget] = useState<any>(null);
const [isRenameDbModalOpen, setIsRenameDbModalOpen] = useState(false);
const [renameDbForm] = Form.useForm();
const [renameDbTarget, setRenameDbTarget] = useState<any>(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) => (
<V2SchemaContextMenuView
dbName={String(node?.dataRef?.dbName || '')}
schemaName={String(node?.dataRef?.schemaName || node?.title || '')}
shortcutPlatform={activeShortcutPlatform}
onAction={(action) => {
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: <EditOutlined />,
onClick: () => openRenameSchemaModal(node)
},
{
key: 'refresh-schema',
label: '刷新',
icon: <ReloadOutlined />,
onClick: () => void loadTables(getDatabaseNodeRef(node.dataRef, node.dataRef.dbName))
},
{
key: 'export-schema',
label: '导出当前模式表结构 (SQL)',
icon: <ExportOutlined />,
onClick: () => void handleExportSchemaSQL(node, false)
},
{
key: 'backup-schema-sql',
label: '备份当前模式全部表 (结构+数据 SQL)',
icon: <SaveOutlined />,
onClick: () => void handleExportSchemaSQL(node, true)
},
{ type: 'divider' },
{
key: 'drop-schema',
label: '删除模式',
icon: <DeleteOutlined />,
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<{
</Form>
</Modal>
<Modal
title={`编辑模式${renameSchemaTarget?.dataRef?.dbName && renameSchemaTarget?.dataRef?.schemaName ? ` (${renameSchemaTarget.dataRef.dbName}.${renameSchemaTarget.dataRef.schemaName})` : ''}`}
open={isRenameSchemaModalOpen}
onOk={handleRenameSchema}
onCancel={() => {
setIsRenameSchemaModalOpen(false);
setRenameSchemaTarget(null);
renameSchemaForm.resetFields();
}}
>
<Form form={renameSchemaForm} layout="vertical">
<Form.Item name="newName" label="模式名称" rules={[{ required: true, message: '请输入模式名称' }]}>
<Input {...noAutoCapInputProps} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`重命名数据库${renameDbTarget?.dataRef?.dbName ? ` (${renameDbTarget.dataRef.dbName})` : ''}`}
open={isRenameDbModalOpen}

View File

@@ -310,6 +310,13 @@ export type V2DatabaseContextMenuActionKey =
| 'run-sql'
| 'drop-db';
export type V2SchemaContextMenuActionKey =
| 'rename-schema'
| 'refresh-schema'
| 'export-schema'
| 'backup-schema-sql'
| 'drop-schema';
export const V2DatabaseContextMenuView: React.FC<{
dbName: string;
shortcutPlatform?: ShortcutPlatform;
@@ -383,6 +390,53 @@ export const V2DatabaseContextMenuView: React.FC<{
);
};
export const V2SchemaContextMenuView: React.FC<{
dbName: string;
schemaName: string;
shortcutPlatform?: ShortcutPlatform;
onAction?: (action: V2SchemaContextMenuActionKey) => void;
}> = ({
dbName,
schemaName,
shortcutPlatform = DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
return (
<div className="gn-v2-table-context-menu gn-v2-database-context-menu" data-v2-schema-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<FolderOpenOutlined />}
title={schemaName}
meta={`${dbName || '当前数据库'} · 模式操作`}
pill="SCHEMA"
/>
<div className="gn-v2-context-menu-body">
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'rename-schema', icon: <EditOutlined />, title: '编辑模式', kbd: 'F2', featured: true },
{ action: 'refresh-schema', icon: <ReloadOutlined />, title: '刷新对象树', kbd: primaryShortcut('R', shortcutPlatform) },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'export-schema', icon: <ExportOutlined />, title: '导出当前模式表结构 · SQL' },
{ action: 'backup-schema-sql', icon: <SaveOutlined />, title: '备份当前模式全部表 · 结构 + 数据' },
])}
<div className="gn-v2-context-menu-divider" />
{renderItems([
{ action: 'drop-schema', icon: <DeleteOutlined />, title: '删除模式 · DROP CASCADE', tone: 'danger', kbd: '⌫' },
])}
</div>
</div>
);
};
const DEFAULT_V2_CONTEXT_MENU_SHORTCUT_PLATFORM: ShortcutPlatform = 'windows';
const primaryShortcut = (

View File

@@ -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<connection.QueryResult>;
export function DropSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
@@ -106,6 +108,8 @@ export function ExportQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:st
export function ExportSQLFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function ExportSchemaSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:boolean):Promise<connection.QueryResult>;
export function ExportTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportTablesDataSQL(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
@@ -266,6 +270,8 @@ export function RenameSQLDirectory(arg1:string,arg2:string):Promise<connection.Q
export function RenameSQLFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
export function RenameSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;

View File

@@ -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);
}

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)
}
}