mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-21 22:14:02 +08:00
✨ feat(schema): 支持模式编辑删除及按模式导出备份
- 新增 PostgreSQL 系模式重命名与删除能力 - 侧栏模式节点补充右键菜单、编辑弹窗和删除确认 - 导出表结构与备份表数据支持按模式过滤表和视图 - 同步补充 Wails 绑定与前后端定向测试 Close #526
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
6
frontend/wailsjs/go/app/App.d.ts
vendored
6
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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