🐛 fix(external-sql): 修复外部 SQL 文件保存不写回源文件

- 保存逻辑:外部 SQL 文件标签页携带 filePath,保存时写回原始磁盘文件
- 后端接口:新增 WriteSQLFile 能力,支持覆盖已有 SQL 文件并保留原文件权限
- 状态隔离:外部文件保存失败时不创建 savedQuery,避免写入 localStorage 副本
- 兼容行为:非文件标签页继续沿用原有 savedQuery 快速保存逻辑
- 文案优化:将数据库下入口改为“外部 SQL 目录”,减少与单文件打开入口的歧义
- 测试覆盖:补充前端保存分支、后端写文件边界和外部 SQL 目录文案测试
Refs #422
This commit is contained in:
Syngnat
2026-04-28 13:26:55 +08:00
parent a07eea7815
commit ef634075ab
11 changed files with 379 additions and 3 deletions

View File

@@ -0,0 +1,279 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import QueryEditor from './QueryEditor';
const storeState = vi.hoisted(() => ({
connections: [
{
id: 'conn-1',
name: 'local',
config: {
type: 'mysql',
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'main',
},
},
],
addSqlLog: vi.fn(),
addTab: vi.fn(),
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: { enabled: false, combo: '' },
},
activeTabId: 'tab-1',
aiPanelVisible: false,
setAIPanelVisible: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBQueryWithCancel: vi.fn(),
DBQueryMulti: vi.fn(),
DBGetTables: vi.fn(),
DBGetAllColumns: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
CancelQuery: vi.fn(),
GenerateQueryID: vi.fn(),
WriteSQLFile: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
}));
const editorState = vi.hoisted(() => {
const state = {
value: '',
editor: null as any,
};
state.editor = {
getValue: vi.fn(() => state.value),
setValue: vi.fn((value: string) => {
state.value = value;
}),
getModel: vi.fn(() => ({
getValue: () => state.value,
setValue: (value: string) => {
state.value = value;
},
getValueInRange: () => '',
getLineContent: () => '',
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
})),
getSelection: vi.fn(() => null),
addAction: vi.fn(),
onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
hasTextFocus: vi.fn(() => true),
};
return state;
});
vi.mock('../store', () => {
const useStore = Object.assign(
(selector: (state: typeof storeState) => any) => selector(storeState),
{ getState: () => storeState },
);
return { useStore };
});
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('../utils/autoFetchVisibility', () => ({
useAutoFetchVisibility: () => false,
}));
vi.mock('@monaco-editor/react', () => ({
default: ({ defaultValue, onMount }: any) => {
React.useEffect(() => {
editorState.value = String(defaultValue || '');
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
registerCompletionItemProvider: vi.fn(),
},
});
}, []);
return <textarea data-editor value={editorState.value} readOnly />;
},
}));
vi.mock('./DataGrid', () => ({
default: () => null,
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
SettingOutlined: Icon,
CloseOutlined: Icon,
StopOutlined: Icon,
RobotOutlined: Icon,
};
});
vi.mock('antd', () => {
const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
<button type="button" disabled={disabled || loading} onClick={onClick} {...rest}>
{children}
</button>
);
Button.Group = ({ children }: any) => <div>{children}</div>;
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
return {
Button,
message: messageApi,
Modal: ({ children, open }: any) => (open ? <section>{children}</section> : null),
Input: ({ value, onChange, placeholder }: any) => <input value={value} onChange={onChange} placeholder={placeholder} />,
Form,
Dropdown: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
Select: () => null,
Tabs: () => null,
};
});
const textContent = (node: any): string =>
(node.children || [])
.map((item: any) => (typeof item === 'string' ? item : textContent(item)))
.join('');
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
const createTab = (overrides: Partial<TabData> = {}): TabData => ({
id: 'tab-1',
title: 'query.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
query: 'select 1;',
...overrides,
});
describe('QueryEditor external SQL save', () => {
beforeEach(() => {
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
});
storeState.addTab.mockReset();
storeState.saveQuery.mockReset();
storeState.savedQueries = [];
storeState.activeTabId = 'tab-1';
messageApi.success.mockReset();
messageApi.error.mockReset();
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
editorState.value = '';
editorState.editor.getValue.mockClear();
editorState.editor.setValue.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('writes external SQL file tabs back to disk without creating saved queries', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 2;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
filePath,
query: 'select 2;',
savedQueryId: undefined,
}));
expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
});
it('does not create saved queries when external SQL file writes fail', async () => {
let renderer: ReactTestRenderer;
const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ filePath })} />);
});
editorState.value = 'select 4;';
await act(async () => {
await findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
expect(storeState.saveQuery).not.toHaveBeenCalled();
expect(storeState.addTab).not.toHaveBeenCalled();
expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
});
it('keeps saved query quick-save behavior for non-file tabs', async () => {
storeState.savedQueries = [
{
id: 'saved-1',
name: '常用查询',
sql: 'select 1;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
},
];
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ savedQueryId: 'saved-1' })} />);
});
editorState.value = 'select 3;';
await act(async () => {
findButton(renderer!, '保存').props.onClick();
});
expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
name: '常用查询',
sql: 'select 3;',
connectionId: 'conn-1',
dbName: 'main',
createdAt: 100,
}));
});
});

View File

@@ -6,7 +6,7 @@ import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
@@ -2204,7 +2204,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return saved;
};
const handleQuickSave = () => {
const handleQuickSave = async () => {
const filePath = String(tab.filePath || '').trim();
if (filePath) {
const sql = getCurrentQuery();
try {
const res = await WriteSQLFile(filePath, sql);
if (!res.success) {
message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
return;
}
addTab({
...tab,
query: sql,
connectionId: currentConnectionId,
dbName: currentDb || tab.dbName || '',
filePath,
savedQueryId: undefined,
});
message.success('SQL 文件已保存!');
} catch (error) {
message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
}
return;
}
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';

View File

@@ -2411,6 +2411,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
connectionId,
dbName,
query: String(data || ''),
filePath,
});
};
@@ -4211,6 +4212,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
size="small"
type="text"
icon={<PlusOutlined />}
title="添加外部 SQL 目录"
aria-label="添加外部 SQL 目录"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();

View File

@@ -134,6 +134,7 @@ 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: '已取消' }),
WriteSQLFile: async (_filePath: string, _content: string) => ({ success: true }),
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
ImportConnectionsPayload: async (raw: string, _password?: string) => {

View File

@@ -407,6 +407,7 @@ export interface TabData {
dbName?: string;
tableName?: string;
query?: string;
filePath?: string;
initialTab?: string;
readOnly?: boolean;
providerMode?: "jmx" | "endpoint" | "agent";

View File

@@ -41,6 +41,7 @@ describe('externalSqlTree helpers', () => {
});
expect(node.type).toBe('external-sql-root');
expect(node.title).toBe('外部 SQL 目录 (1)');
expect(node.children).toHaveLength(1);
expect(node.children?.[0]).toMatchObject({
title: 'scripts',

View File

@@ -117,7 +117,7 @@ export const buildExternalSQLRootNode = ({
});
return {
title: children.length > 0 ? `外部 SQL 文件 (${children.length})` : '外部 SQL 文件',
title: children.length > 0 ? `外部 SQL 目录 (${children.length})` : '外部 SQL 目录',
key: `${dbNodeKey}-external-sql`,
type: 'external-sql-root',
isLeaf: children.length === 0,

View File

@@ -279,3 +279,5 @@ export function TestConnection(arg1:connection.ConnectionConfig):Promise<connect
export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function WriteSQLFile(arg1:string,arg2:string):Promise<connection.QueryResult>;

View File

@@ -549,3 +549,7 @@ export function TestJVMConnection(arg1) {
export function TruncateTables(arg1, arg2, arg3) {
return window['go']['app']['App']['TruncateTables'](arg1, arg2, arg3);
}
export function WriteSQLFile(arg1, arg2) {
return window['go']['app']['App']['WriteSQLFile'](arg1, arg2);
}

View File

@@ -96,6 +96,29 @@ func readSQLFileByPath(filePath string) connection.QueryResult {
return connection.QueryResult{Success: true, Data: string(content)}
}
func writeSQLFileByPath(filePath string, content string) connection.QueryResult {
target := strings.TrimSpace(filePath)
if target == "" {
return connection.QueryResult{Success: false, Message: "文件路径不能为空"}
}
if abs, err := filepath.Abs(target); err == nil {
target = abs
}
info, err := os.Stat(target)
if err != nil {
return connection.QueryResult{Success: false, Message: fmt.Sprintf("无法读取文件信息: %v", err)}
}
if info.IsDir() {
return connection.QueryResult{Success: false, Message: "所选路径不是 SQL 文件"}
}
if err := os.WriteFile(target, []byte(content), info.Mode().Perm()); 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 buildSQLDirectoryEntries(directory string) ([]SQLDirectoryEntry, error) {
entries, err := os.ReadDir(directory)
if err != nil {
@@ -215,6 +238,10 @@ func (a *App) ReadSQLFile(filePath string) connection.QueryResult {
return readSQLFileByPath(filePath)
}
func (a *App) WriteSQLFile(filePath string, content string) connection.QueryResult {
return writeSQLFileByPath(filePath, content)
}
// ExecuteSQLFile 在后端流式读取并执行大 SQL 文件,通过事件推送进度。
// 前端通过 EventsOn("sqlfile:progress", ...) 监听进度。
func (a *App) ExecuteSQLFile(config connection.ConnectionConfig, dbName string, filePath string, jobID string) connection.QueryResult {

View File

@@ -41,6 +41,40 @@ func TestBuildSQLDirectoryEntriesKeepsOnlySQLFilesAndNestedFolders(t *testing.T)
}
}
func TestWriteSQLFileByPathOverwritesExistingSQLFile(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 := writeSQLFileByPath(filePath, "select 2;\n")
if !result.Success {
t.Fatalf("expected sql file write to succeed, got %#v", result)
}
content, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if string(content) != "select 2;\n" {
t.Fatalf("expected file content to be overwritten, got %q", string(content))
}
}
func TestWriteSQLFileByPathRejectsDirectories(t *testing.T) {
result := writeSQLFileByPath(t.TempDir(), "select 1;")
if result.Success {
t.Fatalf("expected directory write to fail, got %#v", result)
}
}
func TestWriteSQLFileByPathRejectsEmptyPath(t *testing.T) {
result := writeSQLFileByPath(" ", "select 1;")
if result.Success {
t.Fatalf("expected empty path write to fail, got %#v", result)
}
}
func TestReadSQLFileByPathReturnsLargeFileMetadata(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "big.sql")
file, err := os.Create(filePath)