feat(table): 支持截断表与清空表操作

Fixes #351
This commit is contained in:
Syngnat
2026-04-11 22:53:04 +08:00
parent 33b21cc5ee
commit 2410aad849
9 changed files with 396 additions and 41 deletions

View File

@@ -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。

View File

@@ -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: '删除表',

View File

@@ -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' },

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

View 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: '清空' };
};

View File

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

View File

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

View File

@@ -985,15 +985,57 @@ func (a *App) ExportDatabaseSQL(config connection.ConnectionConfig, dbName strin
return connection.QueryResult{Success: true, Message: "导出完成"}
}
// TruncateTables 清空指定表的数据(针对 MySQL 使用 TRUNCATEMongoDB 使用 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 FROMMongoDB 使用 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

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