mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-06 20:03:05 +08:00
@@ -30,6 +30,7 @@
|
||||
| #329 | 如果调整了左侧导航栏的宽度后,建议左侧导航栏内增加横向滚动查看 | Fixed | `fcade0f` |
|
||||
| #330 | 建议在查询结果表格中增加自适应内容列宽的功能 | Fixed | `632e57e` |
|
||||
| #331 | 重复连接 DB,一分钟重试了 60 多次 | Fixed | `ca76440` |
|
||||
| #351 | 为什么没有截断和清空表的功能呀? | Fixed | Pending |
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -99,6 +100,12 @@
|
||||
- 处理:新增选区复制 helper,将矩形选区按当前可见行列顺序导出为制表符文本;同时补上工具栏“复制选区”按钮和 `Ctrl/Cmd+C` 快捷键,让拖选后的复制行为更接近 Excel。
|
||||
- 验证:新增 `frontend/src/components/dataGridSelectionCopy.test.ts` 覆盖选区排序与剪贴板文本规整规则,并执行 `frontend` 下 `npm run build` 确认功能接线通过。
|
||||
|
||||
### #351
|
||||
|
||||
- 根因:后端已有批量清空表能力,但前端单表危险操作菜单只暴露了“删除表”,没有把“截断表 / 清空表”作为显式入口提供给用户;同时批量“清空”动作底层语义也混用了 `TRUNCATE/DELETE`。
|
||||
- 处理:后端将“截断表”和“清空表”拆分为显式能力,统一通过 helper 生成多数据库 SQL;前端为 Sidebar 和 TableOverview 的表菜单补上两个危险操作入口,并仅在明确支持 `TRUNCATE TABLE` 的数据库类型上显示“截断表”。
|
||||
- 验证:新增 `internal/app/methods_file_clear_test.go` 与 `frontend/src/components/tableDataDangerActions.test.ts`,并执行 `go test ./...`、`frontend` 下 `npm run build` 确认全量通过。
|
||||
|
||||
## Next
|
||||
|
||||
- 继续处理下一个最早且可直接落地的开放 issue。
|
||||
|
||||
@@ -39,6 +39,7 @@ import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { SavedConnection } from '../types';
|
||||
import { getDbIcon } from './DatabaseIcons';
|
||||
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
@@ -1761,13 +1762,13 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const app = (window as any).go.app.App;
|
||||
const res = await app.TruncateTables(normalizeConnConfig(conn.config), dbName, objectNames);
|
||||
const res = await app.ClearTables(normalizeConnConfig(conn.config), dbName, objectNames);
|
||||
hide();
|
||||
const duration = Date.now() - startTime;
|
||||
if (res.success) {
|
||||
message.success('清空成功');
|
||||
// 构造 SQL 日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) */\n`;
|
||||
let logSql = `/* Clear Tables (${objectNames.length} tables) */\n`;
|
||||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||||
} else {
|
||||
@@ -1786,7 +1787,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
} else if (res.message !== '已取消') {
|
||||
message.error('清空失败: ' + res.message);
|
||||
// 记录失败的日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) - FAILED */\n`;
|
||||
let logSql = `/* Clear Tables (${objectNames.length} tables) - FAILED */\n`;
|
||||
if (res.data && res.data.executedSQLs && Array.isArray(res.data.executedSQLs)) {
|
||||
logSql += res.data.executedSQLs.join(';\n') + ';';
|
||||
} else {
|
||||
@@ -1808,7 +1809,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const errMsg = e?.message || String(e);
|
||||
message.error('清空失败: ' + errMsg);
|
||||
// 记录异常的日志
|
||||
let logSql = `/* Truncate Tables (${objectNames.length} tables) - ERROR */\n`;
|
||||
let logSql = `/* Clear Tables (${objectNames.length} tables) - ERROR */\n`;
|
||||
logSql += objectNames.map(name => name).join('; ');
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
@@ -2269,6 +2270,84 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const handleTableDataDangerAction = async (node: any, action: TableDataDangerActionKind) => {
|
||||
const conn = node.dataRef;
|
||||
const tableName = String(conn.tableName || '').trim();
|
||||
if (!tableName) return;
|
||||
|
||||
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: `确认${label}`,
|
||||
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
const config = buildRuntimeConfig(conn, conn.dbName);
|
||||
const app = (window as any).go.app.App;
|
||||
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||||
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
|
||||
hide();
|
||||
const duration = Date.now() - startTime;
|
||||
const executedSQLs = Array.isArray(res.data?.executedSQLs) ? res.data.executedSQLs : [];
|
||||
const logSql = executedSQLs.length > 0
|
||||
? executedSQLs.join(';\n') + ';'
|
||||
: `/* ${label} ${tableName} */`;
|
||||
|
||||
if (res.success) {
|
||||
message.success(`${progressLabel}成功`);
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'success',
|
||||
duration,
|
||||
message: res.message,
|
||||
dbName: conn.dbName,
|
||||
affectedRows: res.data?.count || 0,
|
||||
});
|
||||
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
|
||||
return;
|
||||
}
|
||||
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: logSql,
|
||||
status: 'error',
|
||||
duration,
|
||||
message: res.message,
|
||||
dbName: conn.dbName,
|
||||
});
|
||||
if (res.message !== '已取消') {
|
||||
message.error(`${progressLabel}失败: ${res.message}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errMsg = e?.message || String(e);
|
||||
hide();
|
||||
addSqlLog({
|
||||
id: Date.now().toString(),
|
||||
timestamp: Date.now(),
|
||||
sql: `/* ${label} ${tableName} - ERROR */`,
|
||||
status: 'error',
|
||||
duration,
|
||||
message: errMsg,
|
||||
dbName: conn.dbName,
|
||||
});
|
||||
message.error(`${progressLabel}失败: ${errMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 视图操作 ---
|
||||
const openViewDefinition = (node: any) => {
|
||||
const { viewName, dbName, id } = node.dataRef;
|
||||
@@ -3519,6 +3598,18 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
label: '危险操作',
|
||||
icon: <WarningOutlined />,
|
||||
children: [
|
||||
...(supportsTableTruncateAction(node.dataRef?.config?.type, node.dataRef?.config?.driver) ? [{
|
||||
key: 'truncate-table',
|
||||
label: '截断表',
|
||||
danger: true,
|
||||
onClick: () => handleTableDataDangerAction(node, 'truncate')
|
||||
}] : []),
|
||||
{
|
||||
key: 'clear-table',
|
||||
label: '清空表',
|
||||
danger: true,
|
||||
onClick: () => handleTableDataDangerAction(node, 'clear')
|
||||
},
|
||||
{
|
||||
key: 'drop-table',
|
||||
label: '删除表',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStore } from '../store';
|
||||
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
|
||||
import type { TabData } from '../types';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
|
||||
|
||||
interface TableOverviewProps {
|
||||
tab: TabData;
|
||||
@@ -281,6 +282,40 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
|
||||
const { label, progressLabel } = getTableDataDangerActionMeta(action);
|
||||
Modal.confirm({
|
||||
title: `确认${label}`,
|
||||
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
const app = (window as any).go.app.App;
|
||||
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
|
||||
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
|
||||
try {
|
||||
const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]);
|
||||
hide();
|
||||
if (res.success) {
|
||||
message.success(`${progressLabel}成功`);
|
||||
loadData();
|
||||
} else {
|
||||
message.error(`${progressLabel}失败: ${res.message}`);
|
||||
return Promise.reject();
|
||||
}
|
||||
} catch (e: any) {
|
||||
hide();
|
||||
message.error(`${progressLabel}失败: ${e?.message || String(e)}`);
|
||||
return Promise.reject();
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [buildConfig, tab.dbName, loadData]);
|
||||
|
||||
const handleRenameTable = useCallback((tableName: string) => {
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
@@ -341,6 +376,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
const maxCombinedSize = sortedFiltered.reduce((max, table) => {
|
||||
return Math.max(max, table.dataSize + table.indexSize);
|
||||
}, 0);
|
||||
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -437,6 +473,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
@@ -521,6 +559,8 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
|
||||
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
|
||||
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
|
||||
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
|
||||
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
|
||||
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
|
||||
]},
|
||||
{ type: 'divider' },
|
||||
|
||||
18
frontend/src/components/tableDataDangerActions.test.ts
Normal file
18
frontend/src/components/tableDataDangerActions.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { supportsTableTruncateAction } from './tableDataDangerActions';
|
||||
|
||||
describe('tableDataDangerActions', () => {
|
||||
it('supports native truncate for known relational dialects', () => {
|
||||
expect(supportsTableTruncateAction('mysql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('postgres')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'postgresql')).toBe(true);
|
||||
expect(supportsTableTruncateAction('custom', 'kingbase8')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects truncate for unsupported or document-style backends', () => {
|
||||
expect(supportsTableTruncateAction('sqlite')).toBe(false);
|
||||
expect(supportsTableTruncateAction('mongodb')).toBe(false);
|
||||
expect(supportsTableTruncateAction('custom', 'sqlite3')).toBe(false);
|
||||
});
|
||||
});
|
||||
82
frontend/src/components/tableDataDangerActions.ts
Normal file
82
frontend/src/components/tableDataDangerActions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type TableDataDangerActionKind = 'truncate' | 'clear';
|
||||
|
||||
const resolveCustomDriverDialect = (driver: string): string => {
|
||||
const normalized = String(driver || '').trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'postgresql':
|
||||
case 'postgres':
|
||||
case 'pg':
|
||||
case 'pq':
|
||||
case 'pgx':
|
||||
return 'postgres';
|
||||
case 'dm':
|
||||
case 'dameng':
|
||||
case 'dm8':
|
||||
return 'dameng';
|
||||
case 'sqlite3':
|
||||
case 'sqlite':
|
||||
return 'sqlite';
|
||||
case 'sphinxql':
|
||||
return 'sphinx';
|
||||
case 'diros':
|
||||
case 'doris':
|
||||
return 'diros';
|
||||
case 'kingbase':
|
||||
case 'kingbase8':
|
||||
case 'kingbasees':
|
||||
case 'kingbasev8':
|
||||
return 'kingbase';
|
||||
case 'highgo':
|
||||
return 'highgo';
|
||||
case 'vastbase':
|
||||
return 'vastbase';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalized.includes('postgres')) return 'postgres';
|
||||
if (normalized.includes('kingbase')) return 'kingbase';
|
||||
if (normalized.includes('highgo')) return 'highgo';
|
||||
if (normalized.includes('vastbase')) return 'vastbase';
|
||||
if (normalized.includes('sqlite')) return 'sqlite';
|
||||
if (normalized.includes('sphinx')) return 'sphinx';
|
||||
if (normalized.includes('diros') || normalized.includes('doris')) return 'diros';
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveTableDataActionDBType = (type: string, driver?: string): string => {
|
||||
const normalizedType = String(type || '').trim().toLowerCase();
|
||||
if (normalizedType !== 'custom') {
|
||||
return normalizedType;
|
||||
}
|
||||
return resolveCustomDriverDialect(driver || '');
|
||||
};
|
||||
|
||||
export const supportsTableTruncateAction = (type: string, driver?: string): boolean => {
|
||||
switch (resolveTableDataActionDBType(type, driver)) {
|
||||
case 'mysql':
|
||||
case 'mariadb':
|
||||
case 'postgres':
|
||||
case 'kingbase':
|
||||
case 'highgo':
|
||||
case 'vastbase':
|
||||
case 'sqlserver':
|
||||
case 'oracle':
|
||||
case 'dameng':
|
||||
case 'clickhouse':
|
||||
case 'duckdb':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind): {
|
||||
label: string;
|
||||
progressLabel: string;
|
||||
} => {
|
||||
if (action === 'truncate') {
|
||||
return { label: '截断表', progressLabel: '截断' };
|
||||
}
|
||||
return { label: '清空表', progressLabel: '清空' };
|
||||
};
|
||||
10
frontend/wailsjs/go/app/App.d.ts
vendored
10
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -16,6 +16,8 @@ export function CheckDriverNetworkStatus():Promise<connection.QueryResult>;
|
||||
|
||||
export function CheckForUpdates():Promise<connection.QueryResult>;
|
||||
|
||||
export function ClearTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise<connection.QueryResult>;
|
||||
@@ -128,12 +130,12 @@ export function MySQLQuery(arg1:connection.ConnectionConfig,arg2:string,arg3:str
|
||||
|
||||
export function MySQLShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDataRootDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDownloadedUpdateDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenDataRootDirectory():Promise<connection.QueryResult>;
|
||||
|
||||
export function OpenSQLFile():Promise<connection.QueryResult>;
|
||||
|
||||
export function PreviewImportFile(arg1:string):Promise<connection.QueryResult>;
|
||||
@@ -204,10 +206,10 @@ export function SaveConnection(arg1:connection.SavedConnectionInput):Promise<con
|
||||
|
||||
export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise<connection.GlobalProxyView>;
|
||||
|
||||
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDataRootDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDatabaseFile(arg1:string,arg2:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
export function SelectDriverPackageDirectory(arg1:string):Promise<connection.QueryResult>;
|
||||
|
||||
@@ -26,6 +26,10 @@ export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function ClearTables(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['ClearTables'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function ConfigureDriverRuntimeDirectory(arg1) {
|
||||
return window['go']['app']['App']['ConfigureDriverRuntimeDirectory'](arg1);
|
||||
}
|
||||
@@ -215,7 +219,7 @@ export function ImportLegacyGlobalProxy(arg1) {
|
||||
}
|
||||
|
||||
export function InstallLocalDriverPackage(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
|
||||
return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function InstallUpdateAndRestart() {
|
||||
@@ -250,6 +254,10 @@ export function MySQLShowCreateTable(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['MySQLShowCreateTable'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function OpenDataRootDirectory() {
|
||||
return window['go']['app']['App']['OpenDataRootDirectory']();
|
||||
}
|
||||
|
||||
export function OpenDownloadedUpdateDirectory() {
|
||||
return window['go']['app']['App']['OpenDownloadedUpdateDirectory']();
|
||||
}
|
||||
@@ -258,10 +266,6 @@ export function OpenDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['OpenDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function OpenDataRootDirectory() {
|
||||
return window['go']['app']['App']['OpenDataRootDirectory']();
|
||||
}
|
||||
|
||||
export function OpenSQLFile() {
|
||||
return window['go']['app']['App']['OpenSQLFile']();
|
||||
}
|
||||
@@ -402,14 +406,14 @@ export function SaveGlobalProxy(arg1) {
|
||||
return window['go']['app']['App']['SaveGlobalProxy'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDatabaseFile(arg1, arg2) {
|
||||
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDataRootDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDataRootDirectory'](arg1);
|
||||
}
|
||||
|
||||
export function SelectDatabaseFile(arg1, arg2) {
|
||||
return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function SelectDriverDownloadDirectory(arg1) {
|
||||
return window['go']['app']['App']['SelectDriverDownloadDirectory'](arg1);
|
||||
}
|
||||
|
||||
@@ -985,15 +985,57 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
|
||||
return connection.QueryResult{Success: true, Message: "导出完成"}
|
||||
}
|
||||
|
||||
// TruncateTables 清空指定表的数据(针对 MySQL 使用 TRUNCATE,MongoDB 使用 delete,否则使用 DELETE)。
|
||||
// 注意:MySQL 的 TRUNCATE TABLE 是 DDL 操作,无法事务回滚;批量清空为逐表执行,
|
||||
// 如果中途失败,已清空的表无法恢复。错误结果会附带已执行的 SQL 列表供排查。
|
||||
func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
|
||||
type tableDataClearMode string
|
||||
|
||||
const (
|
||||
tableDataClearModeTruncate tableDataClearMode = "truncate"
|
||||
tableDataClearModeDeleteAll tableDataClearMode = "delete_all"
|
||||
)
|
||||
|
||||
func supportsTruncateTableForDBType(dbType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(dbType)) {
|
||||
case "mysql", "mariadb", "postgres", "kingbase", "highgo", "vastbase", "sqlserver", "oracle", "dameng", "clickhouse", "duckdb":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildTableDataClearSQL(config connection.ConnectionConfig, objectName string, mode tableDataClearMode) (string, error) {
|
||||
dbType := resolveDDLDBType(config)
|
||||
quotedObject := quoteQualifiedIdentByType(dbType, objectName)
|
||||
|
||||
switch mode {
|
||||
case tableDataClearModeTruncate:
|
||||
if !supportsTruncateTableForDBType(dbType) {
|
||||
return "", fmt.Errorf("当前数据库类型 %s 不支持截断表,请改用清空表", strings.TrimSpace(dbType))
|
||||
}
|
||||
return fmt.Sprintf("TRUNCATE TABLE %s", quotedObject), nil
|
||||
case tableDataClearModeDeleteAll:
|
||||
if dbType == "mongodb" {
|
||||
return fmt.Sprintf(`{"delete":"%s","deletes":[{"q":{},"limit":0}]}`, objectName), nil
|
||||
}
|
||||
return fmt.Sprintf("DELETE FROM %s", quotedObject), nil
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的表数据清理模式: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func tableDataClearActionLabels(mode tableDataClearMode) (actionLabel string, progressLabel string) {
|
||||
switch mode {
|
||||
case tableDataClearModeTruncate:
|
||||
return "截断表", "截断"
|
||||
default:
|
||||
return "清空表", "清空"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runTableDataClear(config connection.ConnectionConfig, dbName string, tableNames []string, mode tableDataClearMode) connection.QueryResult {
|
||||
runConfig := normalizeRunConfig(config, dbName)
|
||||
|
||||
// 参数校验
|
||||
if len(tableNames) == 0 {
|
||||
return connection.QueryResult{Success: false, Message: "未指定要清空的表"}
|
||||
return connection.QueryResult{Success: false, Message: "未指定要处理的表"}
|
||||
}
|
||||
|
||||
objects := make([]string, 0, len(tableNames))
|
||||
@@ -1011,11 +1053,11 @@ func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string,
|
||||
}
|
||||
|
||||
if len(objects) == 0 {
|
||||
return connection.QueryResult{Success: false, Message: "未指定要清空的表"}
|
||||
return connection.QueryResult{Success: false, Message: "未指定要处理的表"}
|
||||
}
|
||||
const maxBatchSize = 200
|
||||
if len(objects) > maxBatchSize {
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("单次最多清空 %d 张表,当前选中 %d 张", maxBatchSize, len(objects))}
|
||||
return connection.QueryResult{Success: false, Message: fmt.Sprintf("单次最多处理 %d 张表,当前选中 %d 张", maxBatchSize, len(objects))}
|
||||
}
|
||||
|
||||
dbInst, err := a.getDatabase(runConfig)
|
||||
@@ -1023,28 +1065,28 @@ func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string,
|
||||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||||
}
|
||||
|
||||
// 审计日志:记录清空操作的发起
|
||||
logger.Warnf("TruncateTables 开始:%s db=%s tables=%v(共 %d 张)", formatConnSummary(runConfig), dbName, objects, len(objects))
|
||||
actionLabel, progressLabel := tableDataClearActionLabels(mode)
|
||||
logger.Warnf("%s 开始:%s db=%s tables=%v(共 %d 张)", actionLabel, formatConnSummary(runConfig), dbName, objects, len(objects))
|
||||
|
||||
dbType := strings.ToLower(strings.TrimSpace(runConfig.Type))
|
||||
var executedSQLs []string
|
||||
for i, objectName := range objects {
|
||||
var sql string
|
||||
if dbType == "mysql" || dbType == "mariadb" {
|
||||
sql = fmt.Sprintf("TRUNCATE TABLE %s", quoteQualifiedIdentByType(runConfig.Type, objectName))
|
||||
} else if dbType == "mongodb" {
|
||||
// MongoDB 使用 delete 命令清空集合中的所有文档
|
||||
// deletes 的 limit 为 0 表示删除所有匹配的文档
|
||||
sql = fmt.Sprintf(`{"delete":"%s","deletes":[{"q":{},"limit":0}]}`, objectName)
|
||||
} else {
|
||||
sql = fmt.Sprintf("DELETE FROM %s", quoteQualifiedIdentByType(runConfig.Type, objectName))
|
||||
sql, sqlErr := buildTableDataClearSQL(runConfig, objectName, mode)
|
||||
if sqlErr != nil {
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
Message: sqlErr.Error(),
|
||||
Data: map[string]interface{}{
|
||||
"executedSQLs": executedSQLs,
|
||||
"count": len(executedSQLs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := dbInst.Exec(sql); err != nil {
|
||||
logger.Warnf("TruncateTables 第 %d/%d 张表失败:%s table=%s err=%v(已成功清空 %d 张)", i+1, len(objects), formatConnSummary(runConfig), objectName, err, len(executedSQLs))
|
||||
errMsg := fmt.Sprintf("清空 %s 失败: %v", objectName, err)
|
||||
logger.Warnf("%s 第 %d/%d 张表失败:%s table=%s err=%v(已成功%s %d 张)", actionLabel, i+1, len(objects), formatConnSummary(runConfig), objectName, err, progressLabel, len(executedSQLs))
|
||||
errMsg := fmt.Sprintf("%s %s 失败: %v", progressLabel, objectName, err)
|
||||
if len(executedSQLs) > 0 {
|
||||
errMsg += fmt.Sprintf("(注意:前 %d 张表已清空且无法恢复)", len(executedSQLs))
|
||||
errMsg += fmt.Sprintf("(注意:前 %d 张表已%s且无法恢复)", len(executedSQLs), progressLabel)
|
||||
}
|
||||
return connection.QueryResult{
|
||||
Success: false,
|
||||
@@ -1058,11 +1100,11 @@ func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string,
|
||||
executedSQLs = append(executedSQLs, sql)
|
||||
}
|
||||
|
||||
logger.Warnf("TruncateTables 完成:%s db=%s 共清空 %d 张表", formatConnSummary(runConfig), dbName, len(executedSQLs))
|
||||
logger.Warnf("%s 完成:%s db=%s 共%s %d 张表", actionLabel, formatConnSummary(runConfig), dbName, progressLabel, len(executedSQLs))
|
||||
|
||||
return connection.QueryResult{
|
||||
Success: true,
|
||||
Message: "清空成功",
|
||||
Message: progressLabel + "成功",
|
||||
Data: map[string]interface{}{
|
||||
"executedSQLs": executedSQLs,
|
||||
"count": len(executedSQLs),
|
||||
@@ -1070,6 +1112,16 @@ func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string,
|
||||
}
|
||||
}
|
||||
|
||||
// TruncateTables 截断指定表的数据;仅在明确支持 TRUNCATE TABLE 的数据库类型上执行。
|
||||
func (a *App) TruncateTables(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
|
||||
return a.runTableDataClear(config, dbName, tableNames, tableDataClearModeTruncate)
|
||||
}
|
||||
|
||||
// ClearTables 清空指定表的数据;关系型数据库使用 DELETE FROM,MongoDB 使用 delete 命令。
|
||||
func (a *App) ClearTables(config connection.ConnectionConfig, dbName string, tableNames []string) connection.QueryResult {
|
||||
return a.runTableDataClear(config, dbName, tableNames, tableDataClearModeDeleteAll)
|
||||
}
|
||||
|
||||
func quoteIdentByType(dbType string, ident string) string {
|
||||
if ident == "" {
|
||||
return ident
|
||||
|
||||
59
internal/app/methods_file_clear_test.go
Normal file
59
internal/app/methods_file_clear_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func TestBuildTableDataClearSQL_TruncateUsesNativeStatementForSupportedDialect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sql, err := buildTableDataClearSQL(connection.ConnectionConfig{Type: "mysql"}, "sales.orders", tableDataClearModeTruncate)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTableDataClearSQL() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if sql != "TRUNCATE TABLE `sales`.`orders`" {
|
||||
t.Fatalf("unexpected truncate sql: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTableDataClearSQL_ClearUsesDeleteForCustomMySQLDriver(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sql, err := buildTableDataClearSQL(connection.ConnectionConfig{Type: "custom", Driver: "mysql"}, "orders", tableDataClearModeDeleteAll)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTableDataClearSQL() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if sql != "DELETE FROM `orders`" {
|
||||
t.Fatalf("unexpected delete sql for custom mysql driver: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTableDataClearSQL_ClearUsesMongoDeleteCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sql, err := buildTableDataClearSQL(connection.ConnectionConfig{Type: "mongodb"}, "logs", tableDataClearModeDeleteAll)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTableDataClearSQL() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if sql != `{"delete":"logs","deletes":[{"q":{},"limit":0}]}` {
|
||||
t.Fatalf("unexpected mongo clear command: %s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTableDataClearSQL_TruncateRejectsUnsupportedDialect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := buildTableDataClearSQL(connection.ConnectionConfig{Type: "sqlite"}, "orders", tableDataClearModeTruncate)
|
||||
if err == nil {
|
||||
t.Fatal("expected truncate to reject sqlite")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "不支持截断表") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user