From 0b9f0448c8b117e09dbe434598950b3065e250df Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 15 Jun 2026 07:21:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(database):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9F=A5=E8=AF=A2=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=92=8C=E8=BF=9E=E6=8E=A5=E9=87=8A=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 查询编辑器仅预取当前库及 SQL 显式引用库的元数据 - 断开侧边栏连接时主动释放同实例后端缓存连接 - 完善 Redis 连接释放和 Wails 前端绑定 - 修复 SQL Server 存储过程消息结果显示 - 调整查询工具栏布局并补充回归测试 Close #541 --- .../QueryEditor.external-sql-save.test.tsx | 195 ++++++++- frontend/src/components/QueryEditor.tsx | 398 +++++++++++++++--- .../QueryEditorToolbar.layout.test.tsx | 11 + .../src/components/QueryEditorToolbar.tsx | 7 +- .../Sidebar.locate-toolbar.test.tsx | 13 + frontend/src/components/Sidebar.tsx | 65 +-- frontend/src/v2-theme.css | 8 +- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/app/app.go | 18 +- internal/app/methods_db.go | 44 ++ internal/app/methods_db_conn_test.go | 76 ++++ internal/app/methods_redis.go | 33 ++ 13 files changed, 754 insertions(+), 120 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 274f759..d8ca840 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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(); + }); + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c2dac12..e28db1e 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 | const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] }); const isSqlCompletionRequestCancelled = (token?: { isCancellationRequested?: boolean } | null) => Boolean(token?.isCancellationRequested); +const clearRecord = (record: Record) => { + 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(); + 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([]); const routinesRef = useRef([]); const visibleDbsRef = useRef([]); // Store visible databases for cross-db intellisense + const metadataFetchKeyRef = useRef(''); + const metadataContextKeyRef = useRef(''); 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) => { + 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(); 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(); 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(); 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(); 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(); 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(); + 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, diff --git a/frontend/src/components/QueryEditorToolbar.layout.test.tsx b/frontend/src/components/QueryEditorToolbar.layout.test.tsx index 839b581..858d26c 100644 --- a/frontend/src/components/QueryEditorToolbar.layout.test.tsx +++ b/frontend/src/components/QueryEditorToolbar.layout.test.tsx @@ -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( diff --git a/frontend/src/components/QueryEditorToolbar.tsx b/frontend/src/components/QueryEditorToolbar.tsx index d910d56..add392f 100644 --- a/frontend/src/components/QueryEditorToolbar.tsx +++ b/frontend/src/components/QueryEditorToolbar.tsx @@ -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 = ({ alignItems: "center", }} > - = ({ 停止 )} - + {isV2Ui && pendingTransactionToolbar}
({ 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(); const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"'); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index a5b8d3e..5688f2e 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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: , - 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: , - 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', diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css index 23c8c76..4600679 100644 --- a/frontend/src/v2-theme.css +++ b/frontend/src/v2-theme.css @@ -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; } diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 6983eda..386c854 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -62,6 +62,8 @@ export function DBQueryMultiTransactional(arg1:connection.ConnectionConfig,arg2: export function DBQueryWithCancel(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function DBReleaseConnection(arg1:connection.ConnectionConfig):Promise; + export function DBRollbackTransaction(arg1:string):Promise; export function DBShowCreateTable(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 72f4c99..8b7f9b7 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -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); } diff --git a/internal/app/app.go b/internal/app/app.go index 4b9b0dd..a033129 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index 2018134..67c1001 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -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() diff --git a/internal/app/methods_db_conn_test.go b/internal/app/methods_db_conn_test.go index ec99d06..c6ab499 100644 --- a/internal/app/methods_db_conn_test.go +++ b/internal/app/methods_db_conn_test.go @@ -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)) + } +} diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 8ac2136..f15e03b 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -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) }