diff --git a/docs/issues/2026-04-11-issue-backlog-tracking.md b/docs/issues/2026-04-11-issue-backlog-tracking.md index da21e55..e540228 100644 --- a/docs/issues/2026-04-11-issue-backlog-tracking.md +++ b/docs/issues/2026-04-11-issue-backlog-tracking.md @@ -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。 diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f6fb258..381cf0c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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((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: , 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: '删除表', diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index db65b99..c23c9ae 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -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 = ({ 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 = ({ 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 = ({ tab }) => { { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(t.name, 'sql') }, { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(t.name) }, { key: 'danger-zone', label: '危险操作', icon: , 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: , danger: true, onClick: () => handleDeleteTable(t.name) } ]}, { type: 'divider' }, @@ -521,6 +559,8 @@ const TableOverview: React.FC = ({ tab }) => { { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(t.name, 'sql') }, { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(t.name) }, { key: 'danger-zone', label: '危险操作', icon: , 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: , danger: true, onClick: () => handleDeleteTable(t.name) } ]}, { type: 'divider' }, diff --git a/frontend/src/components/tableDataDangerActions.test.ts b/frontend/src/components/tableDataDangerActions.test.ts new file mode 100644 index 0000000..ea4fdf2 --- /dev/null +++ b/frontend/src/components/tableDataDangerActions.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/tableDataDangerActions.ts b/frontend/src/components/tableDataDangerActions.ts new file mode 100644 index 0000000..2868f26 --- /dev/null +++ b/frontend/src/components/tableDataDangerActions.ts @@ -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: '清空' }; +}; diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 830420b..2c4382b 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -16,6 +16,8 @@ export function CheckDriverNetworkStatus():Promise; export function CheckForUpdates():Promise; +export function ClearTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; + export function ConfigureDriverRuntimeDirectory(arg1:string):Promise; export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):Promise; @@ -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; +export function OpenDataRootDirectory():Promise; + export function OpenDownloadedUpdateDirectory():Promise; export function OpenDriverDownloadDirectory(arg1:string):Promise; -export function OpenDataRootDirectory():Promise; - export function OpenSQLFile():Promise; export function PreviewImportFile(arg1:string):Promise; @@ -204,10 +206,10 @@ export function SaveConnection(arg1:connection.SavedConnectionInput):Promise; -export function SelectDatabaseFile(arg1:string,arg2:string):Promise; - export function SelectDataRootDirectory(arg1:string):Promise; +export function SelectDatabaseFile(arg1:string,arg2:string):Promise; + export function SelectDriverDownloadDirectory(arg1:string):Promise; export function SelectDriverPackageDirectory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 059e5c8..319dc77 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 81e95ce..af4b587 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -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 diff --git a/internal/app/methods_file_clear_test.go b/internal/app/methods_file_clear_test.go new file mode 100644 index 0000000..86665ef --- /dev/null +++ b/internal/app/methods_file_clear_test.go @@ -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) + } +}