️ perf(database): 优化查询元数据加载和连接释放

- 查询编辑器仅预取当前库及 SQL 显式引用库的元数据
- 断开侧边栏连接时主动释放同实例后端缓存连接
- 完善 Redis 连接释放和 Wails 前端绑定
- 修复 SQL Server 存储过程消息结果显示
- 调整查询工具栏布局并补充回归测试
Close #541
This commit is contained in:
Syngnat
2026-06-15 07:21:00 +08:00
parent 675aae16e9
commit 0b9f0448c8
13 changed files with 754 additions and 120 deletions

View File

@@ -505,8 +505,10 @@ describe('QueryEditor external SQL save', () => {
}
storeState.sqlEditorPendingTransactions[tabId] = transaction;
});
Object.values(backendApp).forEach((fn) => fn.mockReset());
messageApi.success.mockReset();
messageApi.error.mockReset();
messageApi.info.mockReset();
messageApi.warning.mockReset();
backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
backendApp.WriteSQLFile.mockResolvedValue({ success: true });
@@ -915,8 +917,6 @@ describe('QueryEditor external SQL save', () => {
autoFetchState.visible = true;
storeState.connections[0].config.database = '';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'organization' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
@@ -950,8 +950,6 @@ describe('QueryEditor external SQL save', () => {
autoFetchState.visible = true;
storeState.connections[0].config.database = '';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'fs_org_auth_application' }] });
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
@@ -1111,6 +1109,47 @@ describe('QueryEditor external SQL save', () => {
});
});
it('preloads metadata only for the current database when many databases are visible', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = '';
const databaseRows = [
{ Database: 'main' },
...Array.from({ length: 40 }, (_, index) => ({ Database: `tenant_${String(index + 1).padStart(3, '0')}` })),
];
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: databaseRows });
backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => ({
success: true,
data: dbName === 'main' ? [{ Tables_in_main: 'users' }] : [{ [`Tables_in_${dbName}`]: 'unexpected_table' }],
}));
backendApp.DBGetAllColumns.mockImplementation(async (_config: any, dbName: string) => ({
success: true,
data: dbName === 'main' ? [{ tableName: 'users', name: 'id', type: 'bigint' }] : [],
}));
backendApp.DBQuery.mockResolvedValue({ success: true, data: [] });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: 'SELECT * FROM users', dbName: 'main' })} />);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1);
expect(backendApp.DBGetTables.mock.calls.map((call: any[]) => call[1])).toEqual(['main']);
expect(backendApp.DBGetAllColumns.mock.calls.map((call: any[]) => call[1])).toEqual(['main']);
const metadataQueryDbs = new Set(backendApp.DBQuery.mock.calls.map((call: any[]) => call[1]));
expect([...metadataQueryDbs]).toEqual(['main']);
await act(async () => {
renderer.unmount();
});
});
it('suggests columns in WHERE for cross-database MySQL tables with quoted hyphenated database names', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
@@ -3253,6 +3292,154 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.columnNames).toEqual(['name']);
});
it('prefers the first displayable sqlserver procedure result when empty result sets are returned', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: [], rows: [] },
{
statementIndex: 1,
columns: ['insert_sql'],
rows: [
{ insert_sql: "insert into c_user(userid) values('168')" },
{ insert_sql: "insert into c_user(userid) values('169')" },
],
},
{ statementIndex: 1, columns: [], rows: [] },
{ statementIndex: 1, columns: [], rows: [] },
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select 'c_user','userid = ''168''',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('结果 4');
expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({
insert_sql: "insert into c_user(userid) values('168')",
});
});
it('prefers concrete sqlserver procedure rows over affected-row status results', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] },
{ statementIndex: 1, columns: [], rows: [] },
{
statementIndex: 1,
columns: ['insert_sql'],
rows: [
{ insert_sql: "insert into c_user(userid) values('168')" },
],
},
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select 'c_user','userid = ''168''',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(dataGridState.latestProps?.columnNames).toEqual(['insert_sql']);
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({
insert_sql: "insert into c_user(userid) values('168')",
});
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
});
it('prefers sqlserver print output messages over affected-row status results', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] },
{
statementIndex: 1,
columns: [],
rows: [],
messages: [
"insert into c_dyscript(projectid,name) values (1,'demo')",
"insert into c_dyscript(projectid,name) values (2,'next')",
],
},
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select c_dyscript,'projectid = 1',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 2');
expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
});
it('renders top-level sqlserver print messages when result sets contain only status rows', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'hydee';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [
{ statementIndex: 1, columns: ['affectedRows'], rows: [{ affectedRows: 0 }] },
],
messages: [
"insert into c_dyscript(projectid,name) values (1,'demo')",
],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'hydee', query: "p_get_select c_dyscript,'projectid = 1',1" })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 2');
expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
});
it('keeps both tabs when rerunning the same single sqlserver statement with multiple result sets', async () => {
storeState.connections[0].config.type = 'sqlserver';
storeState.connections[0].config.database = 'master';

View File

@@ -37,8 +37,10 @@ import {
} from '../utils/rowLocator';
import { getQueryTabDraft, hasQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
import {
getColumnDefinitionComment,
getColumnDefinitionKey,
getColumnDefinitionName,
getColumnDefinitionType,
} from '../utils/columnDefinition';
import QueryEditorResultsPanel, { type QueryEditorResultSet } from './QueryEditorResultsPanel';
import { SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS } from './QueryEditorTransactionSettings';
@@ -81,6 +83,22 @@ const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> |
const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] });
const isSqlCompletionRequestCancelled = (token?: { isCancellationRequested?: boolean } | null) =>
Boolean(token?.isCancellationRequested);
const clearRecord = (record: Record<string, unknown>) => {
Object.keys(record).forEach((key) => {
delete record[key];
});
};
const resetSharedQueryEditorMetadata = () => {
sharedTablesData = [];
sharedAllColumnsData = [];
sharedViewsData = [];
sharedMaterializedViewsData = [];
sharedTriggersData = [];
sharedRoutinesData = [];
sharedColumnsCacheData = {};
clearRecord(sharedLazyTablesCache);
clearRecord(sharedLazyTablesInFlight);
};
const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_';
@@ -1215,6 +1233,53 @@ const buildQueryEditorAliasMap = (
return aliasMap;
};
const collectQueryEditorReferencedDatabaseNames = (
fullText: string,
currentDb: string,
visibleDbs: string[],
): string[] => {
const result: string[] = [];
const seen = new Set<string>();
const addDb = (dbName: string) => {
const normalized = String(dbName || '').trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
result.push(normalized);
};
addDb(currentDb);
const visibleDbByLower = new Map(
visibleDbs
.map((db) => String(db || '').trim())
.filter(Boolean)
.map((db) => [db.toLowerCase(), db] as const),
);
const commonSchemaNames = new Set(['public', 'dbo', 'sys', 'information_schema', 'pg_catalog', 'mysql', 'performance_schema']);
const tableRegex = QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX;
tableRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tableRegex.exec(String(fullText || ''))) !== null) {
const tableIdent = normalizeCompletionQualifiedName(match[1] || '');
if (!tableIdent) continue;
const parts = tableIdent.split('.');
if (parts.length < 2) continue;
const candidate = visibleDbByLower.get(String(parts[0] || '').toLowerCase());
if (candidate) {
addDb(candidate);
} else if (visibleDbByLower.size === 0) {
const inferredDb = String(parts[0] || '').trim();
const inferredKey = inferredDb.toLowerCase();
if (inferredDb && inferredKey !== String(currentDb || '').trim().toLowerCase() && !commonSchemaNames.has(inferredKey)) {
addDb(inferredDb);
}
}
}
return result;
};
export const resolveQueryEditorNavigationTarget = (
lineContent: string,
column: number,
@@ -1918,6 +1983,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const triggersRef = useRef<CompletionTriggerMeta[]>([]);
const routinesRef = useRef<CompletionRoutineMeta[]>([]);
const visibleDbsRef = useRef<string[]>([]); // Store visible databases for cross-db intellisense
const metadataFetchKeyRef = useRef<string>('');
const metadataContextKeyRef = useRef<string>('');
const connections = useStore(state => state.connections);
const queryCapableConnections = useMemo(
@@ -1996,6 +2063,28 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
} = useSqlEditorTransactionController({ tabId: tab.id });
const autoFetchVisible = useAutoFetchVisibility();
useEffect(() => {
const nextContextKey = [
String(currentConnectionId || '').trim(),
String(currentDb || '').trim().toLowerCase(),
].join('\u0000');
if (metadataContextKeyRef.current === nextContextKey) {
return;
}
metadataContextKeyRef.current = nextContextKey;
metadataFetchKeyRef.current = '';
tablesRef.current = [];
allColumnsRef.current = [];
viewsRef.current = [];
materializedViewsRef.current = [];
triggersRef.current = [];
routinesRef.current = [];
columnsCacheRef.current = {};
if (isActive) {
resetSharedQueryEditorMetadata();
}
}, [currentConnectionId, currentDb, isActive]);
const currentSavedQuery = useMemo(() => {
const savedId = String(tab.savedQueryId || '').trim();
if (savedId) {
@@ -2255,12 +2344,40 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return true;
}, []);
const mergeSidebarDropObjectMetadata = useCallback((payload: ReturnType<typeof decodeSidebarSqlEditorDragPayload>) => {
if (!payload?.text || !payload.dbName) {
return;
}
const nodeType = String(payload.nodeType || '').trim().toLowerCase();
if (nodeType && nodeType !== 'table') {
return;
}
const dbName = String(payload.dbName || '').trim();
const tableName = normalizeCompletionQualifiedName(payload.text);
if (!dbName || !tableName) {
return;
}
const visibleKey = dbName.toLowerCase();
if (!visibleDbsRef.current.some((db) => String(db || '').toLowerCase() === visibleKey)) {
visibleDbsRef.current = [...visibleDbsRef.current, dbName];
}
const tableKey = `${visibleKey}\u0000${tableName.toLowerCase()}`;
if (!tablesRef.current.some((table) => `${String(table.dbName || '').toLowerCase()}\u0000${String(table.tableName || '').toLowerCase()}` === tableKey)) {
tablesRef.current = [...tablesRef.current, { dbName, tableName }];
}
if (isActive) {
sharedVisibleDbs = visibleDbsRef.current;
sharedTablesData = tablesRef.current;
}
}, [isActive]);
const handleSidebarObjectDrop = useCallback((event: DragEvent) => {
if (!hasSidebarSqlEditorDragPayload(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
const payload = decodeSidebarSqlEditorDragPayload(String(event.dataTransfer?.getData(SIDEBAR_SQL_EDITOR_DRAG_MIME) || ''));
const dragText = readSidebarSqlDropText(event, currentConnectionIdRef.current, currentDbRef.current);
if (!dragText) {
return;
@@ -2268,9 +2385,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const editor = editorRef.current;
const dropTarget = editor?.getTargetAtClientPoint?.(event.clientX, event.clientY);
if (insertTextIntoEditorAtPosition(dragText, normalizeEditorPosition(dropTarget?.position))) {
mergeSidebarDropObjectMetadata(payload);
refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH);
}
}, [insertTextIntoEditorAtPosition, refreshObjectDecorations]);
}, [insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations]);
const handleSelectCurrentStatement = () => {
const editor = editorRef.current;
@@ -2378,12 +2496,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
let cancelled = false;
const fetchMetadata = async () => {
const conn = connections.find(c => c.id === currentConnectionId);
if (!conn) return;
const visibleDbs = visibleDbsRef.current;
if (!visibleDbs || visibleDbs.length === 0) return;
const config = {
...conn.config,
@@ -2394,7 +2512,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
ssh: conn.config.ssh || { host: "", port: 22, user: "", password: "", keyPath: "" }
};
// 加载所有可见数据库的表
const metadataDbName = String(currentDbRef.current || currentDb || '').trim();
if (!metadataDbName) return;
const metadataDbNames = collectQueryEditorReferencedDatabaseNames(
getCurrentQuery(),
metadataDbName,
visibleDbs,
);
const metadataFetchKey = [
currentConnectionId,
...metadataDbNames.map((dbName) => String(dbName || '').toLowerCase()),
].join('\u0000');
if (metadataFetchKeyRef.current === metadataFetchKey) return;
metadataFetchKeyRef.current = metadataFetchKey;
const allTables: CompletionTableMeta[] = [];
const allColumns: CompletionColumnMeta[] = [];
const allViews: CompletionViewMeta[] = [];
@@ -2402,13 +2533,35 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const allTriggers: CompletionTriggerMeta[] = [];
const allRoutines: CompletionRoutineMeta[] = [];
const metadataDialect = normalizeMetadataDialect(conn);
const syncMetadataSnapshot = () => {
if (cancelled) {
return false;
}
tablesRef.current = [...allTables];
allColumnsRef.current = [...allColumns];
viewsRef.current = [...allViews];
materializedViewsRef.current = [...allMaterializedViews];
triggersRef.current = [...allTriggers];
routinesRef.current = [...allRoutines];
if (isActive) {
sharedTablesData = tablesRef.current;
sharedAllColumnsData = allColumnsRef.current;
sharedViewsData = viewsRef.current;
sharedMaterializedViewsData = materializedViewsRef.current;
sharedTriggersData = triggersRef.current;
sharedRoutinesData = routinesRef.current;
}
return true;
};
for (const dbName of visibleDbs) {
for (const dbName of metadataDbNames) {
if (cancelled) return;
const tableComments = new Map<string, string>();
const tableCommentSQL = buildCompletionTableCommentSQL(metadataDialect, dbName);
if (tableCommentSQL) {
try {
const resTableComments = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, tableCommentSQL);
if (cancelled) return;
if (resTableComments.success && Array.isArray(resTableComments.data)) {
resTableComments.data.forEach((row: any) => {
const tableName = normalizeCommentText(getCaseInsensitiveValue(row, ['table_name', 'TABLE_NAME', 'name', 'Name']));
@@ -2423,6 +2576,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// 获取表
const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName);
if (cancelled) return;
if (resTables.success && Array.isArray(resTables.data)) {
const tableNames = resTables.data.map((row: any) => Object.values(row)[0] as string);
tableNames.forEach((tableName: string) => {
@@ -2436,9 +2590,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
}
if (!syncMetadataSnapshot()) return;
// 获取列 (所有数据库类型都支持 DBGetAllColumns)
const resCols = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName);
if (cancelled) return;
if (resCols.success && Array.isArray(resCols.data)) {
resCols.data.forEach((col: any) => {
allColumns.push({
@@ -2450,12 +2606,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
}
if (!syncMetadataSnapshot()) return;
const viewResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionViewsMetadataQuerySpecs(metadataDialect, dbName),
);
if (cancelled) return;
const seenViews = new Set<string>();
viewResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
@@ -2478,12 +2636,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
});
if (!syncMetadataSnapshot()) return;
const materializedViewResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionMaterializedViewsMetadataQuerySpecs(metadataDialect, dbName),
);
if (cancelled) return;
const seenMaterializedViews = new Set<string>();
materializedViewResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
@@ -2502,12 +2662,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
});
if (!syncMetadataSnapshot()) return;
const triggerResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionTriggersMetadataQuerySpecs(metadataDialect, dbName),
);
if (cancelled) return;
const seenTriggers = new Set<string>();
triggerResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
@@ -2533,12 +2695,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
});
if (!syncMetadataSnapshot()) return;
const routineResults = await queryCompletionMetadataRowsBySpecs(
config,
dbName,
buildCompletionFunctionsMetadataQuerySpecs(metadataDialect, dbName),
);
if (cancelled) return;
const seenRoutines = new Set<string>();
routineResults.forEach((queryResult) => {
queryResult.rows.forEach((row) => {
@@ -2560,27 +2724,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
});
});
if (!syncMetadataSnapshot()) return;
}
tablesRef.current = allTables;
allColumnsRef.current = allColumns;
viewsRef.current = allViews;
materializedViewsRef.current = allMaterializedViews;
triggersRef.current = allTriggers;
routinesRef.current = allRoutines;
// 如果当前 Tab 是活跃 Tab同步更新共享变量
if (isActive) {
sharedTablesData = allTables;
sharedAllColumnsData = allColumns;
sharedViewsData = allViews;
sharedMaterializedViewsData = allMaterializedViews;
sharedTriggersData = allTriggers;
sharedRoutinesData = allRoutines;
}
if (!syncMetadataSnapshot()) return;
refreshObjectDecorations();
};
void fetchMetadata();
}, [autoFetchVisible, currentConnectionId, connections, dbList, isActive, isObjectEditQueryTab, refreshObjectDecorations]); // dbList 变化时触发重新加载
return () => {
cancelled = true;
};
}, [autoFetchVisible, currentConnectionId, currentDb, connections, isActive, isObjectEditQueryTab, refreshObjectDecorations]);
// Query ID management helpers
const setQueryId = (id: string) => {
@@ -3239,24 +3393,85 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return sharedLazyTablesInFlight[key];
};
const getColumnsByDB = async (tableIdent: string) => {
const toCompletionColumns = (
columns: ColumnDefinition[],
dbName: string,
tableName: string,
): CompletionColumnMeta[] => columns
.map((column) => ({
dbName,
tableName,
name: getColumnDefinitionName(column),
type: getColumnDefinitionType(column),
comment: getColumnDefinitionComment(column),
}))
.filter((column) => !!column.name);
const findPreloadedColumns = (dbName: string, tableName: string) => {
const targetDbLower = String(dbName || '').toLowerCase();
const targetTableLower = String(tableName || '').toLowerCase();
return sharedAllColumnsData.filter((column) => {
if (String(column.dbName || '').toLowerCase() !== targetDbLower) return false;
const columnTableLower = String(column.tableName || '').toLowerCase();
if (columnTableLower === targetTableLower) return true;
const parsed = splitSchemaAndTable(column.tableName || '');
return String(parsed.table || '').toLowerCase() === targetTableLower;
});
};
const mergeSharedCompletionColumns = (columns: CompletionColumnMeta[]) => {
if (columns.length === 0) return;
const existingKeys = new Set(sharedAllColumnsData.map((column) => [
String(column.dbName || '').toLowerCase(),
String(column.tableName || '').toLowerCase(),
String(column.name || '').toLowerCase(),
].join('\u0000')));
const missing = columns.filter((column) => {
const key = [
String(column.dbName || '').toLowerCase(),
String(column.tableName || '').toLowerCase(),
String(column.name || '').toLowerCase(),
].join('\u0000');
if (existingKeys.has(key)) return false;
existingKeys.add(key);
return true;
});
if (missing.length > 0) {
sharedAllColumnsData = [...sharedAllColumnsData, ...missing];
}
};
const getCompletionColumnsByTable = async (dbName: string, tableIdent: string) => {
const connId = sharedCurrentConnectionId;
const dbName = sharedCurrentDb;
if (!connId || !dbName) return [] as ColumnDefinition[];
const key = `${connId}|${dbName}|${tableIdent}`;
const cached = sharedColumnsCacheData[key];
if (cached) return cached;
const targetDb = String(dbName || '').trim();
const targetTable = String(tableIdent || '').trim();
if (!connId || !targetDb || !targetTable) return [] as CompletionColumnMeta[];
const preloaded = findPreloadedColumns(targetDb, targetTable);
if (preloaded.length > 0) {
return preloaded;
}
const key = `${connId}|${targetDb}|${targetTable}`;
const cached = sharedColumnsCacheData[key] as ColumnDefinition[] | undefined;
if (cached) {
const cachedColumns = toCompletionColumns(cached, targetDb, targetTable);
mergeSharedCompletionColumns(cachedColumns);
return cachedColumns;
}
const config = buildConnConfig();
if (!config) return [] as ColumnDefinition[];
if (!config) return [] as CompletionColumnMeta[];
const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableIdent);
const res = await DBGetColumns(buildRpcConnectionConfig(config) as any, targetDb, targetTable);
if (res?.success && Array.isArray(res.data)) {
const cols = res.data as ColumnDefinition[];
sharedColumnsCacheData[key] = cols;
return cols;
const completionColumns = toCompletionColumns(cols, targetDb, targetTable);
mergeSharedCompletionColumns(completionColumns);
return completionColumns;
}
return [] as ColumnDefinition[];
return [] as CompletionColumnMeta[];
};
const fullText = model.getValue();
@@ -3271,11 +3486,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const tablePart = stripQuotes(threePartMatch[2]);
const colPrefix = (threePartMatch[3] || '').toLowerCase();
// 在 allColumnsRef 中查找匹配的列
const cols = sharedAllColumnsData.filter(c =>
(c.dbName || '').toLowerCase() === dbPart.toLowerCase() &&
(c.tableName || '').toLowerCase() === tablePart.toLowerCase()
);
const cols = await getCompletionColumnsByTable(dbPart, tablePart);
if (isSqlCompletionRequestCancelled(token)) {
return createEmptySqlCompletionResult();
}
const filtered = colPrefix
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
@@ -3304,9 +3518,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const visibleDbs = sharedVisibleDbs;
if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) {
// qualifier 是数据库名,提示该库的表
const tables = sharedTablesData.filter(t =>
let tables = sharedTablesData.filter(t =>
(t.dbName || '').toLowerCase() === qualifierLower
);
if (tables.length === 0) {
tables = await getLazyTablesByDB(qualifier);
if (isSqlCompletionRequestCancelled(token)) {
return createEmptySqlCompletionResult();
}
}
const filtered = prefix
? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix))
: tables;
@@ -3358,26 +3578,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const tableInfo = aliasMap[qualifier.toLowerCase()];
if (tableInfo) {
// Prefer preloaded MySQL all-columns cache
let cols: { name: string, type?: string, tableName?: string, dbName?: string, comment?: string }[];
if (sharedAllColumnsData.length > 0) {
const tiTableLower = (tableInfo.tableName || '').toLowerCase();
cols = sharedAllColumnsData
.filter(c => {
if ((c.dbName || '').toLowerCase() !== (tableInfo.dbName || '').toLowerCase()) return false;
const cTableLower = (c.tableName || '').toLowerCase();
if (cTableLower === tiTableLower) return true;
// schema.table 格式匹配纯表名
const parsed = splitSchemaAndTable(c.tableName || '');
return (parsed.table || '').toLowerCase() === tiTableLower;
})
.map(c => ({ name: c.name, type: c.type, tableName: c.tableName, dbName: c.dbName, comment: c.comment }));
} else {
const dbCols = await getColumnsByDB(tableInfo.tableName);
if (isSqlCompletionRequestCancelled(token)) {
return createEmptySqlCompletionResult();
}
cols = dbCols.map(c => ({ name: c.name, type: c.type, tableName: tableInfo.tableName, comment: c.comment }));
const cols = await getCompletionColumnsByTable(tableInfo.dbName, tableInfo.tableName);
if (isSqlCompletionRequestCancelled(token)) {
return createEmptySqlCompletionResult();
}
const filtered = prefix
@@ -3455,9 +3658,30 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
}
const referencedColumns: CompletionColumnMeta[] = [];
if (!expectsTableName) {
const aliasMapForReferencedTables = buildQueryEditorAliasMap(fullText, currentDatabase);
const seenReferencedTables = new Set<string>();
for (const tableInfo of Object.values(aliasMapForReferencedTables)) {
const key = `${String(tableInfo.dbName || '').toLowerCase()}.${String(tableInfo.tableName || '').toLowerCase()}`;
if (!tableInfo.dbName || !tableInfo.tableName || seenReferencedTables.has(key)) continue;
seenReferencedTables.add(key);
const preloaded = findPreloadedColumns(tableInfo.dbName, tableInfo.tableName);
if (preloaded.length > 0) continue;
const cols = await getCompletionColumnsByTable(tableInfo.dbName, tableInfo.tableName);
if (isSqlCompletionRequestCancelled(token)) {
return createEmptySqlCompletionResult();
}
referencedColumns.push(...cols);
}
}
const completionColumns = referencedColumns.length > 0
? [...sharedAllColumnsData, ...referencedColumns]
: sharedAllColumnsData;
// 相关列提示:匹配 SQL 中引用的表FROM/JOIN 等)
// 权重最高,输入 WHERE 条件时优先显示
const relevantColumns = (expectsTableName ? [] : sharedAllColumnsData)
const relevantColumns = (expectsTableName ? [] : completionColumns)
.filter(c => {
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
const shortIdent = (c.tableName || '').toLowerCase();
@@ -3843,8 +4067,55 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return merged;
};
const isDisplayableResultSet = (result?: ResultSet | null): boolean => {
if (!result) {
return false;
}
if (Array.isArray(result.messages) && result.messages.length > 0) {
return true;
}
if (Array.isArray(result.columns) && result.columns.length > 0) {
return true;
}
if (Array.isArray(result.rows) && result.rows.length > 0) {
return true;
}
return false;
};
const isAffectedRowsResultSet = (result?: ResultSet | null): boolean =>
Boolean(
result &&
Array.isArray(result.columns) &&
result.columns.length === 1 &&
result.columns[0] === 'affectedRows',
);
const isMessageLikeResultSet = (result?: ResultSet | null): boolean =>
Boolean(
result &&
Array.isArray(result.messages) &&
result.messages.length > 0 &&
result.resultType !== 'grid',
);
const isConcreteGridResultSet = (result?: ResultSet | null): boolean =>
Boolean(
result &&
result.resultType !== 'message' &&
!isAffectedRowsResultSet(result) &&
(
(Array.isArray(result.columns) && result.columns.length > 0) ||
(Array.isArray(result.rows) && result.rows.length > 0)
),
);
const resolveActiveResultKeyAfterMerge = (merged: ResultSet[], executed: ResultSet[]): string => {
const firstExecutedResult = executed[0];
const firstExecutedResult = executed.find((result) => isConcreteGridResultSet(result))
|| executed.find((result) => isMessageLikeResultSet(result))
|| executed.find((result) => isDisplayableResultSet(result) && !isAffectedRowsResultSet(result))
|| executed.find((result) => isDisplayableResultSet(result))
|| executed[0];
if (!firstExecutedResult) {
return '';
}
@@ -4427,6 +4698,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
// res.data 是 ResultSetData[] 数组
const resultSetDataArray = Array.isArray(res.data) ? (res.data as any[]) : [];
const topLevelMessages = Array.isArray(res.messages)
? (res.messages as any[]).map((item) => String(item ?? '').trim()).filter(Boolean)
: [];
const nextResultSets: ResultSet[] = [];
const maxRows = Number(queryOptions?.maxRows) || 0;
let anyTruncated = false;
@@ -4523,16 +4797,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
}
if (resultSetDataArray.length === 0 && Array.isArray(res.messages) && res.messages.length > 0) {
if (topLevelMessages.length > 0 && !nextResultSets.some((result) => Array.isArray(result.messages) && result.messages.length > 0)) {
nextResultSets.push({
key: 'result-1',
key: `result-${nextResultSets.length + 1}`,
sql: fullSQL,
exportSql: sourceStatements.join(';\n'),
sourceStatementIndex: 1,
statementResultIndex: 1,
statementResultIndex: (statementResultCounts.get(1) || 0) + 1,
rows: [],
columns: [],
messages: res.messages,
messages: topLevelMessages,
resultType: 'message',
pkColumns: [],
readOnly: true,

View File

@@ -28,6 +28,17 @@ describe('QueryEditorToolbar layout', () => {
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair {');
});
it('keeps run and stop buttons separated in the v2 toolbar action group', () => {
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(toolbarSource).toContain('gn-v2-query-toolbar-action-group');
expect(toolbarSource).not.toContain('Space.Compact');
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group {');
expect(css).not.toContain('.gn-v2-query-toolbar-action-group.ant-btn-group');
expect(css).toContain('gap: 6px;');
});
it('keeps commit button hover styling in source and v2 css', () => {
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
const commitBaseCss = css.slice(

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Button, Dropdown, Select, Space, Tooltip, type MenuProps } from "antd";
import { Button, Dropdown, Select, Tooltip, type MenuProps } from "antd";
import {
EyeInvisibleOutlined,
EyeOutlined,
@@ -214,8 +214,9 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
alignItems: "center",
}}
>
<Space.Compact
<div
className={isV2Ui ? "gn-v2-query-toolbar-action-group" : undefined}
style={{ display: "flex", gap: "8px", alignItems: "center" }}
>
<Tooltip
title={
@@ -245,7 +246,7 @@ const QueryEditorToolbar: React.FC<QueryEditorToolbarProps> = ({
</Button>
)}
</Space.Compact>
</div>
{isV2Ui && pendingTransactionToolbar}
<div
className={isV2Ui ? "gn-v2-query-toolbar-action-pair" : undefined}

View File

@@ -167,6 +167,7 @@ vi.mock('../../wailsjs/go/app/App', () => ({
DBGetTables: mocks.noop,
DBQuery: mocks.noop,
DBShowCreateTable: mocks.noop,
DBReleaseConnection: mocks.noop,
ExportTable: mocks.noop,
OpenSQLFile: mocks.noop,
ExecuteSQLFile: mocks.noop,
@@ -498,6 +499,18 @@ describe('Sidebar locate toolbar', () => {
expect(source).toContain('}> = React.memo(({');
});
it('releases backend database connections when disconnecting a sidebar connection', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const disconnectSource = source.slice(
source.indexOf('const releaseConnectionResources = async'),
source.indexOf('const deleteConnectionNode ='),
);
expect(source).toContain('DBReleaseConnection');
expect(disconnectSource).toContain('await releaseConnectionResources(conn);');
expect(source.match(/onClick: \(\) => void disconnectConnectionNode\(node\)/g)).toHaveLength(2);
});
it('renders the current table locate action in the sidebar toolbar', () => {
const markup = renderToStaticMarkup(<Sidebar />);
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');

View File

@@ -55,7 +55,7 @@ import {
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { SavedConnection, SavedQuery, ExternalSQLDirectory, ExternalSQLTreeEntry, JVMCapability, JVMResourceSummary } from '../types';
import { getDbIcon } from './DatabaseIcons';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, DBReleaseConnection, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, CreateSchema, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView, SelectSQLDirectory, ListSQLDirectory, ReadSQLFile, CreateSQLFile, CreateSQLDirectory, DeleteSQLFile, DeleteSQLDirectory, RenameSQLFile, RenameSQLDirectory, JVMProbeCapabilities, GetDriverStatusList } from '../../wailsjs/go/app/App';
import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
@@ -5095,9 +5095,18 @@ const Sidebar: React.FC<{
loadDatabases(node);
};
const disconnectConnectionNode = (node: any) => {
const releaseConnectionResources = async (conn: SavedConnection | undefined) => {
if (!conn?.config) return;
const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any);
if (res && res.success === false) {
throw new Error(res.message || '释放连接失败');
}
};
const disconnectConnectionNode = async (node: any) => {
const connKey = String(node?.key || node?.dataRef?.id || '');
if (!connKey) return;
const conn = (connections.find((item) => item.id === connKey) || node?.dataRef) as SavedConnection | undefined;
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connKey}` || loadingKey.startsWith(`tables-${connKey}-`)) {
loadingNodesRef.current.delete(loadingKey);
@@ -5116,6 +5125,11 @@ const Sidebar: React.FC<{
setLoadedKeys(prev => prev.filter(k => k !== connKey && !k.toString().startsWith(`${connKey}-`)));
replaceTreeNodeChildren(connKey, undefined);
closeTabsByConnection(connKey);
try {
await releaseConnectionResources(conn);
} catch (error: any) {
message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败');
}
message.success("已断开连接");
};
@@ -5205,7 +5219,7 @@ const Sidebar: React.FC<{
void handleDuplicateConnection(node.dataRef as SavedConnection);
return;
case 'disconnect':
disconnectConnectionNode(node);
void disconnectConnectionNode(node);
return;
case 'delete':
deleteConnectionNode(node);
@@ -6849,22 +6863,7 @@ const Sidebar: React.FC<{
key: 'disconnect',
label: '断开连接',
icon: <DisconnectOutlined />,
onClick: () => {
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === node.key || k.startsWith(`${node.key}-`)) {
delete next[k];
}
});
return next;
});
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
replaceTreeNodeChildren(node.key, undefined);
closeTabsByConnection(String(node.key));
message.success("已断开连接");
}
onClick: () => void disconnectConnectionNode(node)
},
{
key: 'delete',
@@ -6989,33 +6988,7 @@ const Sidebar: React.FC<{
key: 'disconnect',
label: '断开连接',
icon: <DisconnectOutlined />,
onClick: () => {
const connId = String(node.key || '');
// 强制清理该连接相关的 loading 标记,避免网络卡住后重连仍被短路。
Array.from(loadingNodesRef.current).forEach((loadingKey) => {
if (loadingKey === `dbs-${connId}` || loadingKey.startsWith(`tables-${connId}-`)) {
loadingNodesRef.current.delete(loadingKey);
}
});
// Reset status recursively
setConnectionStates(prev => {
const next = { ...prev };
Object.keys(next).forEach(k => {
if (k === node.key || k.startsWith(`${node.key}-`)) {
delete next[k];
}
});
return next;
});
// Collapse node and children
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
// Reset loaded state recursively
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
// Clear children (undefined to trigger reload)
replaceTreeNodeChildren(node.key, undefined);
closeTabsByConnection(String(node.key));
message.success("已断开连接");
}
onClick: () => void disconnectConnectionNode(node)
},
{
key: 'delete',

View File

@@ -4886,7 +4886,7 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar .ant-btn {
font-size: 12.5px !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group {
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group {
display: inline-flex !important;
align-items: center;
flex: 0 0 auto;
@@ -4900,16 +4900,16 @@ body[data-ui-version="v2"] .gn-v2-query-toolbar-action-pair {
gap: 8px;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group > .ant-btn {
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group > .ant-btn {
flex: 0 0 auto;
border-radius: 9px !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group > .ant-btn:not(:first-child) {
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group > .ant-btn:not(:first-child) {
margin-left: 0 !important;
}
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group.ant-btn-group > .ant-btn::before {
body[data-ui-version="v2"] .gn-v2-query-toolbar-action-group > .ant-btn::before {
display: none !important;
}

View File

@@ -62,6 +62,8 @@ export function DBQueryMultiTransactional(arg1:connection.ConnectionConfig,arg2:
export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function DBReleaseConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function DBRollbackTransaction(arg1:string):Promise<connection.QueryResult>;
export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise<connection.QueryResult>;

View File

@@ -114,6 +114,10 @@ export function DBQueryWithCancel(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['DBQueryWithCancel'](arg1, arg2, arg3, arg4);
}
export function DBReleaseConnection(arg1) {
return window['go']['app']['App']['DBReleaseConnection'](arg1);
}
export function DBRollbackTransaction(arg1) {
return window['go']['app']['App']['DBRollbackTransaction'](arg1);
}

View File

@@ -43,6 +43,7 @@ var (
type cachedDatabase struct {
inst db.Database
lastPing time.Time
config connection.ConnectionConfig
}
type cachedConnectFailure struct {
@@ -189,6 +190,7 @@ func (a *App) Shutdown() {
logger.Error(err, "关闭数据库连接失败")
}
}
a.dbCache = make(map[string]cachedDatabase)
proxytunnel.CloseAllForwarders()
// Close all Redis connections
CloseAllRedisClients()
@@ -291,6 +293,20 @@ func getCacheKey(config connection.ConnectionConfig) string {
return hex.EncodeToString(sum[:])
}
func normalizeConnectionReleaseMatchConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
normalized := normalizeCacheKeyConfig(config)
normalized.Database = ""
normalized.RedisDB = 0
return normalized
}
func getConnectionReleaseMatchKey(config connection.ConnectionConfig) string {
normalized := normalizeConnectionReleaseMatchConfig(config)
b, _ := json.Marshal(normalized)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func shortCacheKey(cacheKey string) string {
shortKey := cacheKey
if len(shortKey) > 12 {
@@ -726,7 +742,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing
}
return existing.inst, nil
}
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now}
a.dbCache[key] = cachedDatabase{inst: dbInst, lastPing: now, config: normalizeCacheKeyConfig(effectiveConfig)}
a.mu.Unlock()
logger.Infof("数据库连接成功并写入缓存:%s 缓存Key=%s", formatConnSummary(effectiveConfig), shortKey)

View File

@@ -63,6 +63,50 @@ func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResu
return connection.QueryResult{Success: true, Message: "连接成功"}
}
func (a *App) DBReleaseConnection(config connection.ConnectionConfig) connection.QueryResult {
dbType := strings.ToLower(strings.TrimSpace(config.Type))
if dbType == "redis" {
closed, err := a.releaseRedisClientsForConfig(config)
if err != nil {
logger.Error(err, "DBReleaseConnection 释放 Redis 连接失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
logger.Infof("DBReleaseConnection 已释放 Redis 连接:%s 数量=%d", formatConnSummary(config), closed)
return connection.QueryResult{Success: true, Message: "连接已释放", Data: map[string]int{"closed": closed}}
}
resolvedConfig, err := a.resolveConnectionSecrets(config)
if err != nil {
wrapped := wrapConnectError(config, err)
logger.Error(wrapped, "DBReleaseConnection 解析连接密文失败:%s", formatConnSummary(config))
return connection.QueryResult{Success: false, Message: wrapped.Error()}
}
targetKey := getConnectionReleaseMatchKey(applyGlobalProxyToConnection(resolvedConfig))
closed := 0
a.mu.Lock()
for key, entry := range a.dbCache {
entryConfig := entry.config
if strings.TrimSpace(entryConfig.Type) == "" {
continue
}
if getConnectionReleaseMatchKey(entryConfig) != targetKey {
continue
}
if entry.inst != nil {
if closeErr := entry.inst.Close(); closeErr != nil {
logger.Error(closeErr, "DBReleaseConnection 关闭缓存连接失败缓存Key=%s", shortCacheKey(key))
}
}
delete(a.dbCache, key)
closed++
}
a.mu.Unlock()
logger.Infof("DBReleaseConnection 已释放数据库连接:%s 数量=%d", formatConnSummary(resolvedConfig), closed)
return connection.QueryResult{Success: true, Message: "连接已释放", Data: map[string]int{"closed": closed}}
}
func (a *App) TestConnection(config connection.ConnectionConfig) connection.QueryResult {
testConfig := normalizeTestConnectionConfig(config)
started := time.Now()

View File

@@ -7,6 +7,41 @@ import (
"GoNavi-Wails/internal/connection"
)
type releaseRecordingDB struct {
closed int
}
func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { return nil }
func (f *releaseRecordingDB) Close() error {
f.closed++
return nil
}
func (f *releaseRecordingDB) Ping() error { return nil }
func (f *releaseRecordingDB) Query(query string) ([]map[string]interface{}, []string, error) {
return nil, nil, nil
}
func (f *releaseRecordingDB) Exec(query string) (int64, error) { return 0, nil }
func (f *releaseRecordingDB) GetDatabases() ([]string, error) { return nil, nil }
func (f *releaseRecordingDB) GetTables(dbName string) ([]string, error) { return nil, nil }
func (f *releaseRecordingDB) GetCreateStatement(dbName, tableName string) (string, error) {
return "", nil
}
func (f *releaseRecordingDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) {
return nil, nil
}
func (f *releaseRecordingDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) {
return nil, nil
}
func (f *releaseRecordingDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) {
return nil, nil
}
func (f *releaseRecordingDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) {
return nil, nil
}
func (f *releaseRecordingDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
return nil, nil
}
func TestNormalizeTestConnectionConfig_CapsTimeout(t *testing.T) {
cfg := connection.ConnectionConfig{Timeout: 60}
got := normalizeTestConnectionConfig(cfg)
@@ -130,3 +165,44 @@ func TestFormatConnSummary_DefaultTimeout(t *testing.T) {
t.Fatalf("formatConnSummary 默认超时应为30s, got=%q", got)
}
}
func TestDBReleaseConnectionClosesAllDatabaseCacheEntriesForSameInstance(t *testing.T) {
app := NewApp()
mainConfig := connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root", Database: "main"}
analyticsConfig := mainConfig
analyticsConfig.Database = "analytics"
otherConfig := mainConfig
otherConfig.Port = 3307
otherConfig.Database = "main"
mainDB := &releaseRecordingDB{}
analyticsDB := &releaseRecordingDB{}
otherDB := &releaseRecordingDB{}
app.dbCache[getCacheKey(mainConfig)] = cachedDatabase{
inst: mainDB,
config: normalizeCacheKeyConfig(mainConfig),
}
app.dbCache[getCacheKey(analyticsConfig)] = cachedDatabase{
inst: analyticsDB,
config: normalizeCacheKeyConfig(analyticsConfig),
}
app.dbCache[getCacheKey(otherConfig)] = cachedDatabase{
inst: otherDB,
config: normalizeCacheKeyConfig(otherConfig),
}
result := app.DBReleaseConnection(connection.ConnectionConfig{Type: "mysql", Host: "127.0.0.1", Port: 3306, User: "root"})
if !result.Success {
t.Fatalf("expected release success, got %s", result.Message)
}
if mainDB.closed != 1 || analyticsDB.closed != 1 {
t.Fatalf("expected both same-instance cached connections closed, got main=%d analytics=%d", mainDB.closed, analyticsDB.closed)
}
if otherDB.closed != 0 {
t.Fatalf("expected other instance cache to remain open, got closed=%d", otherDB.closed)
}
if len(app.dbCache) != 1 {
t.Fatalf("expected only unrelated cache entry to remain, got %d", len(app.dbCache))
}
}

View File

@@ -19,6 +19,7 @@ import (
// Redis client cache
var (
redisCache = make(map[string]redis.RedisClient)
redisCacheConfigs = make(map[string]connection.ConnectionConfig)
redisCacheMu sync.Mutex
newRedisClientFunc = redis.NewRedisClient
)
@@ -60,6 +61,7 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
}
client.Close()
delete(redisCache, key)
delete(redisCacheConfigs, key)
}
logger.Infof("创建 Redis 客户端实例缓存Key=%s", shortKey)
@@ -71,6 +73,7 @@ func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisCli
}
redisCache[key] = client
redisCacheConfigs[key] = normalizeCacheKeyConfig(connectedConfig)
logger.Infof("Redis 连接成功并写入缓存:%s 缓存Key=%s", formatRedisConnSummary(connectedConfig), shortKey)
return client, nil
}
@@ -142,6 +145,35 @@ func getRedisClientCacheKey(config connection.ConnectionConfig) string {
return hex.EncodeToString(sum[:])
}
func (a *App) releaseRedisClientsForConfig(config connection.ConnectionConfig) (int, error) {
resolvedConfig, err := a.resolveConnectionSecrets(config)
if err != nil {
return 0, wrapConnectError(config, err)
}
targetKey := getConnectionReleaseMatchKey(applyGlobalProxyToConnection(resolvedConfig))
closed := 0
redisCacheMu.Lock()
defer redisCacheMu.Unlock()
for key, client := range redisCache {
entryConfig := redisCacheConfigs[key]
if strings.TrimSpace(entryConfig.Type) == "" {
continue
}
if getConnectionReleaseMatchKey(entryConfig) != targetKey {
continue
}
if client != nil {
client.Close()
}
delete(redisCache, key)
delete(redisCacheConfigs, key)
closed++
}
return closed, nil
}
func formatRedisConnSummary(config connection.ConnectionConfig) string {
var b strings.Builder
b.WriteString("类型=redis 地址=")
@@ -759,4 +791,5 @@ func CloseAllRedisClients() {
}
}
redisCache = make(map[string]redis.RedisClient)
redisCacheConfigs = make(map[string]connection.ConnectionConfig)
}