diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7cd2603..7b64034 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -215,6 +215,12 @@ if (typeof window !== 'undefined' && !(window as any).go) { SelectSQLDirectory: async (currentPath: string) => ({ success: false, message: currentPath ? '已取消' : '已取消' }), ListSQLDirectory: async () => ({ success: true, data: [] }), ReadSQLFile: async () => ({ success: false, message: '已取消' }), + CreateSQLFile: async (_directoryPath: string, _name: string) => ({ success: true, data: { filePath: '', name: _name } }), + CreateSQLDirectory: async (directoryPath: string, name: string) => ({ success: true, data: { directoryPath: `${directoryPath}/${name}`, name } }), + DeleteSQLFile: async (_filePath: string) => ({ success: true }), + DeleteSQLDirectory: async (_directoryPath: string) => ({ success: true }), + RenameSQLFile: async (_filePath: string, name: string) => ({ success: true, data: { filePath: _filePath, name } }), + RenameSQLDirectory: async (directoryPath: string, name: string) => ({ success: true, data: { directoryPath: `${directoryPath.replace(/[\\/][^\\/]*$/, '')}/${name}`, name } }), WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }), ExportSQLFile: async (_defaultName: string, _content: string) => ({ success: false, message: '浏览器 mock 不支持 SQL 文件导出' }), InstallUpdateAndRestart: async () => ({ success: false }), @@ -302,4 +308,3 @@ ReactDOM.createRoot(rootNode).render( {rootComponent} , ) - diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 38ccb05..75b5898 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -472,8 +472,8 @@ export interface ExternalSQLDirectory { id: string; name: string; path: string; - connectionId: string; - dbName: string; + connectionId?: string; + dbName?: string; createdAt: number; } diff --git a/frontend/src/utils/externalSqlTree.test.ts b/frontend/src/utils/externalSqlTree.test.ts index f41d2a4..d06cd5b 100644 --- a/frontend/src/utils/externalSqlTree.test.ts +++ b/frontend/src/utils/externalSqlTree.test.ts @@ -10,8 +10,6 @@ describe('externalSqlTree helpers', () => { id: 'dir-1', name: 'scripts', path: 'D:/sql/scripts', - connectionId: 'conn-1', - dbName: 'demo', createdAt: 1, }, ]; @@ -33,14 +31,12 @@ describe('externalSqlTree helpers', () => { }; const node = buildExternalSQLRootNode({ - dbNodeKey: 'conn-1-demo', - connectionId: 'conn-1', - dbName: 'demo', directories, directoryTrees: trees, }); expect(node.type).toBe('external-sql-root'); + expect(node.key).toBe('external-sql-root'); expect(node.title).toBe('外部 SQL 目录 (1)'); expect(node.children).toHaveLength(1); expect(node.children?.[0]).toMatchObject({ @@ -65,4 +61,77 @@ describe('externalSqlTree helpers', () => { expect(first).toContain('demo'); expect(first).not.toBe(second); }); + + it('filters non-sql file entries even when the backend returns them', () => { + const directories: ExternalSQLDirectory[] = [ + { + id: 'dir-1', + name: 'scripts', + path: 'D:/sql/scripts', + createdAt: 1, + }, + ]; + const trees: Record = { + 'dir-1': [ + { + name: 'readme.md', + path: 'D:/sql/scripts/readme.md', + isDir: false, + }, + { + name: 'nested', + path: 'D:/sql/scripts/nested', + isDir: true, + children: [ + { + name: 'notes.txt', + path: 'D:/sql/scripts/nested/notes.txt', + isDir: false, + }, + { + name: 'report.SQL', + path: 'D:/sql/scripts/nested/report.SQL', + isDir: false, + }, + ], + }, + { + name: 'docs', + path: 'D:/sql/scripts/docs', + isDir: true, + children: [ + { + name: 'manual.md', + path: 'D:/sql/scripts/docs/manual.md', + isDir: false, + }, + ], + }, + ], + }; + + const node = buildExternalSQLRootNode({ + directories, + directoryTrees: trees, + }); + + const folderChildren = node.children?.[0].children || []; + const docsFolder = folderChildren.find((child) => child.title === 'docs'); + const nestedFolder = folderChildren.find((child) => child.title === 'nested'); + expect(folderChildren).toHaveLength(2); + expect(docsFolder).toMatchObject({ + title: 'docs', + type: 'external-sql-folder', + }); + expect(docsFolder?.children).toBeUndefined(); + expect(nestedFolder).toMatchObject({ + title: 'nested', + type: 'external-sql-folder', + }); + expect(nestedFolder?.children).toHaveLength(1); + expect(nestedFolder?.children?.[0]).toMatchObject({ + title: 'report.SQL', + type: 'external-sql-file', + }); + }); }); diff --git a/frontend/src/utils/externalSqlTree.ts b/frontend/src/utils/externalSqlTree.ts index 380ad23..a1cfaa6 100644 --- a/frontend/src/utils/externalSqlTree.ts +++ b/frontend/src/utils/externalSqlTree.ts @@ -16,9 +16,9 @@ export interface ExternalSQLTreeNode { } type BuildExternalSQLRootNodeParams = { - dbNodeKey: string; - connectionId: string; - dbName: string; + dbNodeKey?: string; + connectionId?: string; + dbName?: string; directories: ExternalSQLDirectory[]; directoryTrees: Record; }; @@ -35,7 +35,7 @@ const resolveDirectoryDisplayName = (directory: ExternalSQLDirectory): string => }; export const buildExternalSQLDirectoryId = (connectionId: string, dbName: string, directoryPath: string): string => - `external-sql-dir:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(directoryPath)}`; + `external-sql-dir:${normalizeExternalSQLPath(directoryPath)}`; export const buildExternalSQLTabId = (connectionId: string, dbName: string, filePath: string): string => `external-sql-tab:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(filePath)}`; @@ -43,14 +43,20 @@ export const buildExternalSQLTabId = (connectionId: string, dbName: string, file const buildExternalSQLNodeKey = (type: ExternalSQLNodeType, base: string): string => `${type}:${normalizeExternalSQLPath(base)}`; +const isExternalSQLFileEntry = (entry: ExternalSQLTreeEntry): boolean => { + const name = String(entry.name || '').trim(); + const path = normalizeExternalSQLPath(entry.path); + return /\.sql$/i.test(name) || /\.sql$/i.test(path); +}; + const mapExternalSQLTreeEntries = ( entries: ExternalSQLTreeEntry[], context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string }, -): ExternalSQLTreeNode[] => entries.map((entry) => { +): ExternalSQLTreeNode[] => entries.flatMap((entry): ExternalSQLTreeNode[] => { const entryPath = normalizeExternalSQLPath(entry.path); if (entry.isDir) { const children = mapExternalSQLTreeEntries(entry.children || [], context); - return { + return [{ title: entry.name, key: buildExternalSQLNodeKey('external-sql-folder', entryPath), type: 'external-sql-folder', @@ -64,10 +70,14 @@ const mapExternalSQLTreeEntries = ( path: entry.path, name: entry.name, }, - }; + }]; } - return { + if (!isExternalSQLFileEntry(entry)) { + return []; + } + + return [{ title: entry.name, key: buildExternalSQLNodeKey('external-sql-file', entryPath), type: 'external-sql-file', @@ -80,13 +90,13 @@ const mapExternalSQLTreeEntries = ( path: entry.path, name: entry.name, }, - }; + }]; }); export const buildExternalSQLRootNode = ({ - dbNodeKey, - connectionId, - dbName, + dbNodeKey = 'external-sql-root', + connectionId = '', + dbName = '', directories, directoryTrees, }: BuildExternalSQLRootNodeParams): ExternalSQLTreeNode => { @@ -118,7 +128,7 @@ export const buildExternalSQLRootNode = ({ return { title: children.length > 0 ? `外部 SQL 目录 (${children.length})` : '外部 SQL 目录', - key: `${dbNodeKey}-external-sql`, + key: dbNodeKey === 'external-sql-root' ? 'external-sql-root' : `${dbNodeKey}-external-sql`, type: 'external-sql-root', isLeaf: children.length === 0, children: children.length > 0 ? children : undefined, diff --git a/frontend/src/utils/sidebarLocate.test.ts b/frontend/src/utils/sidebarLocate.test.ts index 8b73688..93af743 100644 --- a/frontend/src/utils/sidebarLocate.test.ts +++ b/frontend/src/utils/sidebarLocate.test.ts @@ -122,6 +122,29 @@ describe('sidebarLocate', () => { }); }); + it('builds and resolves locate requests from external SQL file query tabs', () => { + const request = normalizeSidebarLocateObjectRequestFromTab({ + id: 'external-sql-tab:conn-1:main:/Users/me/sql/report.sql', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + filePath: '/Users/me/sql/report.sql', + }); + + expect(request).toMatchObject({ + connectionId: 'conn-1', + dbName: 'main', + filePath: '/Users/me/sql/report.sql', + objectGroup: 'externalSqlFiles', + }); + + expect(resolveSidebarLocateTarget(request!, { groupBySchema: false })).toMatchObject({ + objectGroupKey: 'external-sql-root', + expectedAncestorKeys: ['external-sql-root'], + filePath: '/Users/me/sql/report.sql', + }); + }); + it('keeps StarRocks materialized view tabs on the materialized views branch', () => { const request = normalizeSidebarLocateObjectRequestFromTab({ id: 'view-def-conn-1-main-sales.mv_daily', @@ -285,4 +308,38 @@ describe('sidebarLocate', () => { 'conn-1-main-routine-reporting.refresh_stats', ]); }); + + it('finds external SQL file paths from loaded tree data', () => { + const target = resolveSidebarLocateTarget({ + filePath: 'C:\\Users\\me\\sql\\report.sql', + objectGroup: 'externalSqlFiles', + }, { groupBySchema: false }); + + const tree = [ + { + key: 'external-sql-root', + type: 'external-sql-root', + children: [ + { + key: 'external-sql-directory:C:/Users/me/sql', + type: 'external-sql-directory', + dataRef: { path: 'C:/Users/me/sql' }, + children: [ + { + key: 'external-sql-file:C:/Users/me/sql/report.sql', + type: 'external-sql-file', + dataRef: { path: 'C:/Users/me/sql/report.sql' }, + }, + ], + }, + ], + }, + ]; + + expect(findSidebarNodePathForLocate(tree, target)).toEqual([ + 'external-sql-root', + 'external-sql-directory:C:/Users/me/sql', + 'external-sql-file:C:/Users/me/sql/report.sql', + ]); + }); }); diff --git a/frontend/src/utils/sidebarLocate.ts b/frontend/src/utils/sidebarLocate.ts index 88f8b89..d5f7a26 100644 --- a/frontend/src/utils/sidebarLocate.ts +++ b/frontend/src/utils/sidebarLocate.ts @@ -1,14 +1,26 @@ -export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines'; +export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines' | 'externalSqlFiles'; +export type SidebarLocateDatabaseObjectGroup = Exclude; -export interface SidebarLocateObjectRequest { +export interface SidebarLocateDatabaseObjectRequest { tabId?: string; connectionId: string; dbName: string; tableName: string; schemaName?: string; - objectGroup: SidebarLocateObjectGroup; + objectGroup: SidebarLocateDatabaseObjectGroup; } +export interface SidebarLocateExternalSQLFileRequest { + tabId?: string; + connectionId?: string; + dbName?: string; + filePath: string; + fileName?: string; + objectGroup: 'externalSqlFiles'; +} + +export type SidebarLocateObjectRequest = SidebarLocateDatabaseObjectRequest | SidebarLocateExternalSQLFileRequest; + export interface SidebarLocateTarget { connectionKey: string; databaseKey: string; @@ -21,6 +33,7 @@ export interface SidebarLocateTarget { dbName: string; tableName: string; schemaName: string; + filePath?: string; } export interface SidebarLocateTreeNodeLike { @@ -40,10 +53,13 @@ export interface SidebarLocateTabLike { viewKind?: string; triggerName?: string; routineName?: string; + filePath?: string; } const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); +const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedString(value).replace(/\\/g, '/'); + export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { const raw = toTrimmedString(qualifiedName); if (!raw) return { schemaName: '', objectName: '' }; @@ -55,7 +71,7 @@ export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: }; }; -const inferObjectGroup = (detail: Record, connectionId: string, dbName: string): SidebarLocateObjectGroup => { +const inferObjectGroup = (detail: Record, connectionId: string, dbName: string): SidebarLocateDatabaseObjectGroup => { const explicitGroup = toTrimmedString(detail.objectGroup); if (explicitGroup === 'views' || explicitGroup === 'view') return 'views'; if (explicitGroup === 'materializedViews' || explicitGroup === 'materialized-view') return 'materializedViews'; @@ -80,6 +96,18 @@ const inferObjectGroup = (detail: Record, connectionId: string, export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLocateObjectRequest | null => { const raw = (detail || {}) as Record; + const filePath = normalizeExternalSQLLocatePath(raw.filePath); + if (filePath) { + return { + tabId: toTrimmedString(raw.tabId) || undefined, + connectionId: toTrimmedString(raw.connectionId) || undefined, + dbName: toTrimmedString(raw.dbName) || undefined, + filePath, + fileName: toTrimmedString(raw.fileName || raw.title) || undefined, + objectGroup: 'externalSqlFiles', + }; + } + const connectionId = toTrimmedString(raw.connectionId); const dbName = toTrimmedString(raw.dbName); const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName || raw.triggerName || raw.routineName); @@ -103,6 +131,17 @@ export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLoc export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTabLike | null | undefined): SidebarLocateObjectRequest | null => { if (!tab) return null; + const filePath = normalizeExternalSQLLocatePath(tab.filePath); + if (tab.type === 'query' && filePath) { + return normalizeSidebarLocateObjectRequest({ + tabId: tab.id, + connectionId: tab.connectionId, + dbName: tab.dbName, + filePath, + fileName: tab.id, + }); + } + const objectName = tab.type === 'view-def' ? toTrimmedString(tab.viewName || tab.tableName) : tab.type === 'trigger' @@ -129,6 +168,23 @@ export const resolveSidebarLocateTarget = ( request: SidebarLocateObjectRequest, options: { groupBySchema: boolean }, ): SidebarLocateTarget => { + if (request.objectGroup === 'externalSqlFiles') { + const filePath = normalizeExternalSQLLocatePath(request.filePath); + return { + connectionKey: toTrimmedString(request.connectionId), + databaseKey: request.connectionId && request.dbName ? `${request.connectionId}-${request.dbName}` : '', + targetKey: request.tabId || filePath, + objectGroup: 'externalSqlFiles', + objectGroupKey: 'external-sql-root', + expectedAncestorKeys: ['external-sql-root'], + connectionId: toTrimmedString(request.connectionId), + dbName: toTrimmedString(request.dbName), + tableName: request.fileName || filePath.split('/').filter(Boolean).pop() || filePath, + schemaName: '', + filePath, + }; + } + const connectionKey = request.connectionId; const databaseKey = `${request.connectionId}-${request.dbName}`; const fallbackTargetKey = request.objectGroup === 'materializedViews' @@ -205,6 +261,12 @@ const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: st const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => { const dataRef = node.dataRef || {}; + + if (target.objectGroup === 'externalSqlFiles') { + return node.type === 'external-sql-file' + && normalizeExternalSQLLocatePath(dataRef.path) === normalizeExternalSQLLocatePath(target.filePath); + } + const nodeConnectionId = toTrimmedString(dataRef.id || dataRef.connectionId); const nodeDbName = toTrimmedString(dataRef.dbName); diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 207e81b..6a76148 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -28,6 +28,10 @@ export function ConfigureGlobalProxy(arg1:boolean,arg2:connection.ProxyConfig):P export function CreateDatabase(arg1:connection.ConnectionConfig,arg2:string):Promise; +export function CreateSQLDirectory(arg1:string,arg2:string):Promise; + +export function CreateSQLFile(arg1:string,arg2:string):Promise; + export function CreateSchema(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; export function DBConnect(arg1:connection.ConnectionConfig):Promise; @@ -64,6 +68,10 @@ export function DataSyncPreview(arg1:sync.SyncConfig,arg2:string,arg3:number):Pr export function DeleteConnection(arg1:string):Promise; +export function DeleteSQLDirectory(arg1:string):Promise; + +export function DeleteSQLFile(arg1:string):Promise; + export function DismissSecurityUpdateReminder():Promise; export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; @@ -246,6 +254,10 @@ export function RemoveDriverPackage(arg1:string,arg2:string):Promise; +export function RenameSQLDirectory(arg1:string,arg2:string):Promise; + +export function RenameSQLFile(arg1:string,arg2:string):Promise; + export function RenameTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function RenameView(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 6f6c076..0cfd05e 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -46,6 +46,14 @@ export function CreateDatabase(arg1, arg2) { return window['go']['app']['App']['CreateDatabase'](arg1, arg2); } +export function CreateSQLDirectory(arg1, arg2) { + return window['go']['app']['App']['CreateSQLDirectory'](arg1, arg2); +} + +export function CreateSQLFile(arg1, arg2) { + return window['go']['app']['App']['CreateSQLFile'](arg1, arg2); +} + export function CreateSchema(arg1, arg2, arg3) { return window['go']['app']['App']['CreateSchema'](arg1, arg2, arg3); } @@ -118,6 +126,14 @@ export function DeleteConnection(arg1) { return window['go']['app']['App']['DeleteConnection'](arg1); } +export function DeleteSQLDirectory(arg1) { + return window['go']['app']['App']['DeleteSQLDirectory'](arg1); +} + +export function DeleteSQLFile(arg1) { + return window['go']['app']['App']['DeleteSQLFile'](arg1); +} + export function DismissSecurityUpdateReminder() { return window['go']['app']['App']['DismissSecurityUpdateReminder'](); } @@ -482,6 +498,14 @@ export function RenameDatabase(arg1, arg2, arg3) { return window['go']['app']['App']['RenameDatabase'](arg1, arg2, arg3); } +export function RenameSQLDirectory(arg1, arg2) { + return window['go']['app']['App']['RenameSQLDirectory'](arg1, arg2); +} + +export function RenameSQLFile(arg1, arg2) { + return window['go']['app']['App']['RenameSQLFile'](arg1, arg2); +} + export function RenameTable(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['RenameTable'](arg1, arg2, arg3, arg4); } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 35acb44..19dbd22 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -84,6 +84,200 @@ type SQLDirectoryEntry struct { Children []SQLDirectoryEntry `json:"children,omitempty"` } +func normalizeSQLFileName(rawName string) (string, error) { + name := strings.TrimSpace(rawName) + if name == "" { + return "", fmt.Errorf("SQL 文件名不能为空") + } + if strings.ContainsAny(name, `/\`) || name == "." || name == ".." { + return "", fmt.Errorf("SQL 文件名不能包含路径分隔符") + } + if !strings.EqualFold(filepath.Ext(name), ".sql") { + name += ".sql" + } + return name, nil +} + +func normalizeSQLDirectoryName(rawName string) (string, error) { + name := strings.TrimSpace(rawName) + if name == "" { + return "", fmt.Errorf("目录名不能为空") + } + if strings.ContainsAny(name, `/\`) || name == "." || name == ".." { + return "", fmt.Errorf("目录名不能包含路径分隔符") + } + return name, nil +} + +func normalizeSQLDirectoryPath(directoryPath string) (string, error) { + target := strings.TrimSpace(directoryPath) + if target == "" { + return "", fmt.Errorf("目录路径不能为空") + } + if abs, err := filepath.Abs(target); err == nil { + target = abs + } + info, err := os.Stat(target) + if err != nil { + return "", fmt.Errorf("无法读取目录信息: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("所选路径不是目录") + } + return target, nil +} + +func normalizeExistingSQLDirectoryPath(directoryPath string) (string, os.FileInfo, error) { + target := strings.TrimSpace(directoryPath) + if target == "" { + return "", nil, fmt.Errorf("目录路径不能为空") + } + if abs, err := filepath.Abs(target); err == nil { + target = abs + } + info, err := os.Stat(target) + if err != nil { + return "", nil, fmt.Errorf("无法读取目录信息: %w", err) + } + if !info.IsDir() { + return "", nil, fmt.Errorf("所选路径不是目录") + } + return target, info, nil +} + +func normalizeExistingSQLFilePath(filePath string) (string, os.FileInfo, error) { + target := strings.TrimSpace(filePath) + if target == "" { + return "", nil, fmt.Errorf("文件路径不能为空") + } + if abs, err := filepath.Abs(target); err == nil { + target = abs + } + info, err := os.Stat(target) + if err != nil { + return "", nil, fmt.Errorf("无法读取文件信息: %w", err) + } + if info.IsDir() { + return "", nil, fmt.Errorf("所选路径不是 SQL 文件") + } + if !strings.EqualFold(filepath.Ext(target), ".sql") { + return "", nil, fmt.Errorf("仅支持 SQL 文件") + } + return target, info, nil +} + +func createSQLFileInDirectory(directoryPath string, rawName string) connection.QueryResult { + directory, err := normalizeSQLDirectoryPath(directoryPath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + name, err := normalizeSQLFileName(rawName) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + target := filepath.Join(directory, name) + if _, err := os.Stat(target); err == nil { + return connection.QueryResult{Success: false, Message: "SQL 文件已存在"} + } else if !os.IsNotExist(err) { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)} + } + if err := os.WriteFile(target, []byte(""), 0o644); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法创建 SQL 文件: %v", err)} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target, "name": filepath.Base(target)}} +} + +func createSQLDirectoryInDirectory(parentPath string, rawName string) connection.QueryResult { + parent, err := normalizeSQLDirectoryPath(parentPath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + name, err := normalizeSQLDirectoryName(rawName) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + target := filepath.Join(parent, name) + if _, err := os.Stat(target); err == nil { + return connection.QueryResult{Success: false, Message: "目录已存在"} + } else if !os.IsNotExist(err) { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取目录信息: %v", err)} + } + if err := os.Mkdir(target, 0o755); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法创建目录: %v", err)} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"directoryPath": target, "name": filepath.Base(target)}} +} + +func deleteSQLFileByPath(filePath string) connection.QueryResult { + target, _, err := normalizeExistingSQLFilePath(filePath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := os.Remove(target); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法删除 SQL 文件: %v", err)} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target}} +} + +func deleteSQLDirectoryByPath(directoryPath string) connection.QueryResult { + target, _, err := normalizeExistingSQLDirectoryPath(directoryPath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if err := os.Remove(target); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法删除目录: %v(仅支持删除空目录)", err)} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"directoryPath": target}} +} + +func renameSQLFileByPath(filePath string, rawName string) connection.QueryResult { + source, _, err := normalizeExistingSQLFilePath(filePath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + name, err := normalizeSQLFileName(rawName) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + target := filepath.Join(filepath.Dir(source), name) + if source == target { + return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target, "name": filepath.Base(target)}} + } + if _, err := os.Stat(target); err == nil { + return connection.QueryResult{Success: false, Message: "目标 SQL 文件已存在"} + } else if !os.IsNotExist(err) { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取目标文件信息: %v", err)} + } + if err := os.Rename(source, target); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法重命名 SQL 文件: %v", err)} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"filePath": target, "name": filepath.Base(target)}} +} + +func renameSQLDirectoryByPath(directoryPath string, rawName string) connection.QueryResult { + source, _, err := normalizeExistingSQLDirectoryPath(directoryPath) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + name, err := normalizeSQLDirectoryName(rawName) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + target := filepath.Join(filepath.Dir(source), name) + if source == target { + return connection.QueryResult{Success: true, Data: map[string]interface{}{"directoryPath": target, "name": filepath.Base(target)}} + } + if _, err := os.Stat(target); err == nil { + return connection.QueryResult{Success: false, Message: "目标目录已存在"} + } else if !os.IsNotExist(err) { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取目标目录信息: %v", err)} + } + if err := os.Rename(source, target); err != nil { + return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法重命名目录: %v", err)} + } + return connection.QueryResult{Success: true, Data: map[string]interface{}{"directoryPath": target, "name": filepath.Base(target)}} +} + func normalizeDirectoryDialogPath(currentDir string) string { defaultDir := strings.TrimSpace(currentDir) if defaultDir == "" { @@ -140,6 +334,28 @@ func readSQLFileByPath(filePath string) connection.QueryResult { return connection.QueryResult{Success: true, Data: string(content)} } +func readSQLFileWithMetadataByPath(filePath string) connection.QueryResult { + result := readSQLFileByPath(filePath) + if !result.Success { + return result + } + if data, ok := result.Data.(map[string]interface{}); ok { + return connection.QueryResult{Success: true, Data: data} + } + selection := strings.TrimSpace(filePath) + if abs, err := filepath.Abs(selection); err == nil { + selection = abs + } + return connection.QueryResult{ + Success: true, + Data: map[string]interface{}{ + "content": result.Data, + "filePath": selection, + "name": filepath.Base(selection), + }, + } +} + func writeSQLFileByPath(filePath string, content string) connection.QueryResult { target := strings.TrimSpace(filePath) if target == "" { @@ -238,9 +454,6 @@ func buildSQLDirectoryEntries(directory string) ([]SQLDirectoryEntry, error) { if childErr != nil { return nil, childErr } - if len(children) == 0 { - continue - } result = append(result, SQLDirectoryEntry{ Name: entry.Name(), Path: entryPath, @@ -291,7 +504,7 @@ func (a *App) OpenSQLFile() connection.QueryResult { return connection.QueryResult{Success: false, Message: "已取消"} } - return readSQLFileByPath(selection) + return readSQLFileWithMetadataByPath(selection) } func (a *App) SelectSQLDirectory(currentDir string) connection.QueryResult { @@ -347,6 +560,30 @@ func (a *App) WriteSQLFile(filePath string, content string) connection.QueryResu return writeSQLFileByPath(filePath, content) } +func (a *App) CreateSQLFile(directoryPath string, name string) connection.QueryResult { + return createSQLFileInDirectory(directoryPath, name) +} + +func (a *App) CreateSQLDirectory(directoryPath string, name string) connection.QueryResult { + return createSQLDirectoryInDirectory(directoryPath, name) +} + +func (a *App) DeleteSQLFile(filePath string) connection.QueryResult { + return deleteSQLFileByPath(filePath) +} + +func (a *App) DeleteSQLDirectory(directoryPath string) connection.QueryResult { + return deleteSQLDirectoryByPath(directoryPath) +} + +func (a *App) RenameSQLFile(filePath string, name string) connection.QueryResult { + return renameSQLFileByPath(filePath, name) +} + +func (a *App) RenameSQLDirectory(directoryPath string, name string) connection.QueryResult { + return renameSQLDirectoryByPath(directoryPath, name) +} + func (a *App) ExportSQLFile(defaultName string, content string) connection.QueryResult { filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "导出 SQL 文件", diff --git a/internal/app/methods_file_sql_directory_test.go b/internal/app/methods_file_sql_directory_test.go index 49d28e9..2b7aae7 100644 --- a/internal/app/methods_file_sql_directory_test.go +++ b/internal/app/methods_file_sql_directory_test.go @@ -9,9 +9,13 @@ import ( func TestBuildSQLDirectoryEntriesKeepsOnlySQLFilesAndNestedFolders(t *testing.T) { root := t.TempDir() nestedDir := filepath.Join(root, "nested") + emptyDir := filepath.Join(root, "empty") if err := os.MkdirAll(nestedDir, 0o755); err != nil { t.Fatalf("MkdirAll returned error: %v", err) } + if err := os.MkdirAll(emptyDir, 0o755); err != nil { + t.Fatalf("MkdirAll empty returned error: %v", err) + } if err := os.WriteFile(filepath.Join(root, "z-last.sql"), []byte("select 1;"), 0o644); err != nil { t.Fatalf("WriteFile sql returned error: %v", err) } @@ -27,17 +31,20 @@ func TestBuildSQLDirectoryEntriesKeepsOnlySQLFilesAndNestedFolders(t *testing.T) t.Fatalf("buildSQLDirectoryEntries returned error: %v", err) } - if len(entries) != 2 { - t.Fatalf("expected one folder and one sql file, got %d entries", len(entries)) + if len(entries) != 3 { + t.Fatalf("expected two folders and one sql file, got %d entries", len(entries)) } - if !entries[0].IsDir || entries[0].Name != "nested" { - t.Fatalf("expected nested directory first, got %#v", entries[0]) + if !entries[0].IsDir || entries[0].Name != "empty" || len(entries[0].Children) != 0 { + t.Fatalf("expected empty directory first, got %#v", entries[0]) } - if len(entries[0].Children) != 1 || entries[0].Children[0].Name != "inner.SQL" { - t.Fatalf("expected nested sql child, got %#v", entries[0].Children) + if !entries[1].IsDir || entries[1].Name != "nested" { + t.Fatalf("expected nested directory second, got %#v", entries[1]) } - if entries[1].IsDir || entries[1].Name != "z-last.sql" { - t.Fatalf("expected top-level sql file second, got %#v", entries[1]) + if len(entries[1].Children) != 1 || entries[1].Children[0].Name != "inner.SQL" { + t.Fatalf("expected nested sql child, got %#v", entries[1].Children) + } + if entries[2].IsDir || entries[2].Name != "z-last.sql" { + t.Fatalf("expected top-level sql file third, got %#v", entries[2]) } } @@ -75,6 +82,156 @@ func TestWriteSQLFileByPathRejectsEmptyPath(t *testing.T) { } } +func TestCreateSQLFileInDirectoryCreatesEmptySQLFile(t *testing.T) { + root := t.TempDir() + + result := createSQLFileInDirectory(root, "draft") + if !result.Success { + t.Fatalf("expected sql file create to succeed, got %#v", result) + } + + filePath := filepath.Join(root, "draft.sql") + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + if string(data) != "" { + t.Fatalf("expected empty sql file, got %q", string(data)) + } +} + +func TestCreateSQLFileInDirectoryRejectsPathTraversalName(t *testing.T) { + result := createSQLFileInDirectory(t.TempDir(), "../escape.sql") + if result.Success { + t.Fatalf("expected path traversal name to fail, got %#v", result) + } +} + +func TestCreateSQLDirectoryInDirectoryCreatesEmptyDirectory(t *testing.T) { + root := t.TempDir() + + result := createSQLDirectoryInDirectory(root, "reports") + if !result.Success { + t.Fatalf("expected sql directory create to succeed, got %#v", result) + } + + target := filepath.Join(root, "reports") + info, err := os.Stat(target) + if err != nil { + t.Fatalf("Stat returned error: %v", err) + } + if !info.IsDir() { + t.Fatalf("expected target to be a directory") + } +} + +func TestCreateSQLDirectoryInDirectoryRejectsPathTraversalName(t *testing.T) { + result := createSQLDirectoryInDirectory(t.TempDir(), "../escape") + if result.Success { + t.Fatalf("expected path traversal directory name to fail, got %#v", result) + } +} + +func TestDeleteSQLFileByPathRemovesExistingSQLFile(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "old.sql") + if err := os.WriteFile(filePath, []byte("select 1;"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + result := deleteSQLFileByPath(filePath) + if !result.Success { + t.Fatalf("expected sql file delete to succeed, got %#v", result) + } + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("expected deleted sql file to be gone, stat err=%v", err) + } +} + +func TestDeleteSQLFileByPathRejectsNonSQLFile(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "notes.txt") + if err := os.WriteFile(filePath, []byte("skip"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + result := deleteSQLFileByPath(filePath) + if result.Success { + t.Fatalf("expected non sql file delete to fail, got %#v", result) + } +} + +func TestDeleteSQLDirectoryByPathRemovesEmptyDirectory(t *testing.T) { + directoryPath := filepath.Join(t.TempDir(), "old") + if err := os.Mkdir(directoryPath, 0o755); err != nil { + t.Fatalf("Mkdir returned error: %v", err) + } + + result := deleteSQLDirectoryByPath(directoryPath) + if !result.Success { + t.Fatalf("expected sql directory delete to succeed, got %#v", result) + } + if _, err := os.Stat(directoryPath); !os.IsNotExist(err) { + t.Fatalf("expected deleted sql directory to be gone, stat err=%v", err) + } +} + +func TestDeleteSQLDirectoryByPathRejectsNonEmptyDirectory(t *testing.T) { + directoryPath := filepath.Join(t.TempDir(), "non-empty") + if err := os.Mkdir(directoryPath, 0o755); err != nil { + t.Fatalf("Mkdir returned error: %v", err) + } + if err := os.WriteFile(filepath.Join(directoryPath, "query.sql"), []byte("select 1;"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + result := deleteSQLDirectoryByPath(directoryPath) + if result.Success { + t.Fatalf("expected non-empty sql directory delete to fail, got %#v", result) + } + if _, err := os.Stat(directoryPath); err != nil { + t.Fatalf("expected non-empty sql directory to remain, stat err=%v", err) + } +} + +func TestRenameSQLFileByPathRenamesWithinSameDirectory(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "old.sql") + if err := os.WriteFile(source, []byte("select 1;"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + result := renameSQLFileByPath(source, "new-name") + if !result.Success { + t.Fatalf("expected sql file rename to succeed, got %#v", result) + } + target := filepath.Join(root, "new-name.sql") + if _, err := os.Stat(target); err != nil { + t.Fatalf("expected target sql file, got err=%v", err) + } + if _, err := os.Stat(source); !os.IsNotExist(err) { + t.Fatalf("expected source sql file to be gone, stat err=%v", err) + } +} + +func TestRenameSQLDirectoryByPathRenamesWithinSameParent(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "old") + if err := os.Mkdir(source, 0o755); err != nil { + t.Fatalf("Mkdir returned error: %v", err) + } + + result := renameSQLDirectoryByPath(source, "new-name") + if !result.Success { + t.Fatalf("expected sql directory rename to succeed, got %#v", result) + } + target := filepath.Join(root, "new-name") + if info, err := os.Stat(target); err != nil || !info.IsDir() { + t.Fatalf("expected target sql directory, got info=%#v err=%v", info, err) + } + if _, err := os.Stat(source); !os.IsNotExist(err) { + t.Fatalf("expected source sql directory to be gone, stat err=%v", err) + } +} + func TestNormalizeSQLExportDefaultFilename(t *testing.T) { tests := []struct { name string @@ -144,3 +301,29 @@ func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) { t.Fatalf("expected filePath %q, got %#v", filePath, data["filePath"]) } } + +func TestReadSQLFileWithMetadataByPathReturnsSmallFileContentAndPath(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "report.sql") + if err := os.WriteFile(filePath, []byte("select 1;"), 0o644); err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + + result := readSQLFileWithMetadataByPath(filePath) + if !result.Success { + t.Fatalf("expected sql file read to succeed, got %#v", result) + } + + data, ok := result.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected metadata map, got %#v", result.Data) + } + if data["content"] != "select 1;" { + t.Fatalf("expected content, got %#v", data["content"]) + } + if data["filePath"] != filePath { + t.Fatalf("expected filePath %q, got %#v", filePath, data["filePath"]) + } + if data["name"] != "report.sql" { + t.Fatalf("expected name report.sql, got %#v", data["name"]) + } +}