mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-17 12:09:39 +08:00
⚡️ perf(database): 优化查询元数据加载和连接释放
- 查询编辑器仅预取当前库及 SQL 显式引用库的元数据 - 断开侧边栏连接时主动释放同实例后端缓存连接 - 完善 Redis 连接释放和 Wails 前端绑定 - 修复 SQL Server 存储过程消息结果显示 - 调整查询工具栏布局并补充回归测试 Close #541
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user