mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
🐛 fix(query-editor): 修复新建查询数据库上下文错误
This commit is contained in:
@@ -236,6 +236,7 @@ import {
|
||||
import { useAppUpdateManager } from './hooks/useAppUpdateManager';
|
||||
import { useAppLogPanelResize } from './hooks/useAppLogPanelResize';
|
||||
import { useAppSidebarResize } from './hooks/useAppSidebarResize';
|
||||
import { resolveNewQueryContext } from './utils/newQueryContext';
|
||||
import { useAppUtilityStyles } from './hooks/useAppUtilityStyles';
|
||||
import { useWorkbenchTabs } from './hooks/useWorkbenchTabs';
|
||||
import {
|
||||
@@ -2721,30 +2722,19 @@ function App() {
|
||||
}, [emitWindowDiagnostic, macWindowDiagnosticsEnabled]);
|
||||
|
||||
const handleNewQuery = useCallback(() => {
|
||||
let connId = '';
|
||||
let db = '';
|
||||
|
||||
// Priority: Active Tab Context (if connection still valid) > Sidebar Selection (activeContext)
|
||||
if (activeTabId) {
|
||||
const currentTab = tabs.find(t => t.id === activeTabId);
|
||||
if (currentTab && currentTab.connectionId && connections.some(c => c.id === currentTab.connectionId)) {
|
||||
connId = currentTab.connectionId;
|
||||
db = currentTab.dbName || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Sidebar selection context (only if connection still valid)
|
||||
if (!connId && activeContext?.connectionId && connections.some(c => c.id === activeContext.connectionId)) {
|
||||
connId = activeContext.connectionId;
|
||||
db = activeContext.dbName || '';
|
||||
}
|
||||
const currentTab = activeTabId ? tabs.find(tab => tab.id === activeTabId) : undefined;
|
||||
const targetContext = resolveNewQueryContext({
|
||||
sidebarContext: activeContext,
|
||||
activeTab: currentTab,
|
||||
validConnectionIds: new Set(connections.map(connection => connection.id)),
|
||||
});
|
||||
|
||||
addTab({
|
||||
id: `query-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: t('query.new'),
|
||||
type: 'query',
|
||||
connectionId: connId,
|
||||
dbName: db,
|
||||
connectionId: targetContext.connectionId,
|
||||
dbName: targetContext.dbName,
|
||||
query: ''
|
||||
});
|
||||
}, [activeTabId, tabs, connections, activeContext, addTab, t]);
|
||||
|
||||
@@ -18,6 +18,7 @@ import QueryEditor, {
|
||||
resolveQueryEditorNavigationDecorations,
|
||||
resolveQueryEditorNavigationTarget,
|
||||
} from './QueryEditor';
|
||||
import QueryEditorToolbar from './QueryEditorToolbar';
|
||||
const mountedRenderers = new Set<ReactTestRenderer>();
|
||||
const create = (...args: Parameters<typeof createRenderer>): ReactTestRenderer => {
|
||||
const renderer = createRenderer(...args);
|
||||
@@ -3230,16 +3231,26 @@ describe('QueryEditor external SQL save', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps table name completion available after typing in a fresh query tab', async () => {
|
||||
it('loads table completions after selecting a database in a connection-scoped query tab', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
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: [{ Tables_in_main: 'organization' }] });
|
||||
backendApp.DBGetTables.mockImplementation(async (_config: unknown, dbName: string) => ({
|
||||
success: true,
|
||||
data: dbName === 'main'
|
||||
? [{ Tables_in_main: 'organization' }]
|
||||
: [{ Tables_in_database_a: 'legacy_table' }],
|
||||
}));
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: '' })} />);
|
||||
renderer = create(
|
||||
<>
|
||||
<QueryEditor tab={createTab({ id: 'old-tab', dbName: 'database_a' })} isActive={false} />
|
||||
<QueryEditor tab={createTab({ dbName: '', query: '' })} isActive />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -3249,13 +3260,31 @@ describe('QueryEditor external SQL save', () => {
|
||||
|
||||
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
expect(backendApp.DBGetTables).not.toHaveBeenCalled();
|
||||
|
||||
let immediateCompletion!: Promise<any>;
|
||||
await act(async () => {
|
||||
const activeToolbar = renderer.root.findAllByType(QueryEditorToolbar).find((toolbar) => toolbar.props.currentDb === '');
|
||||
expect(activeToolbar).toBeTruthy();
|
||||
activeToolbar!.props.onDatabaseChange('main');
|
||||
|
||||
editorState.value = 'SELECT * FROM org';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
immediateCompletion = sqlProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 1, column: editorState.value.length + 1 },
|
||||
);
|
||||
await immediateCompletion;
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.any(Object), 'main');
|
||||
});
|
||||
expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', expect.objectContaining({
|
||||
dbName: 'main',
|
||||
}));
|
||||
expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' });
|
||||
|
||||
editorState.value = 'SELECT * FROM org';
|
||||
editorState.latestOnChange?.(editorState.value);
|
||||
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
|
||||
const result = await immediateCompletion;
|
||||
|
||||
expect(result.suggestions.map((item: any) => item.label)).toContain('organization');
|
||||
await act(async () => {
|
||||
@@ -3263,6 +3292,34 @@ describe('QueryEditor external SQL save', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the database empty after loading options for a connection-scoped query tab', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ Database: 'information_schema' }, { Database: 'main' }],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ dbName: '', query: '' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1);
|
||||
expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', expect.objectContaining({
|
||||
dbName: '',
|
||||
}));
|
||||
expect(backendApp.DBGetTables).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('suggests Oracle views after their metadata has loaded', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
|
||||
@@ -2159,19 +2159,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}, [finishPendingSqlTransaction, handleShowSqlExecutionLog]);
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
useEffect(() => {
|
||||
const resetMetadataForContext = useCallback((
|
||||
connectionId: string,
|
||||
dbName: string,
|
||||
connectionConfig: unknown,
|
||||
) => {
|
||||
const nextContextKey = [
|
||||
String(currentConnectionId || '').trim(),
|
||||
String(currentDb || '').trim().toLowerCase(),
|
||||
String(connectionId || '').trim(),
|
||||
String(dbName || '').trim().toLowerCase(),
|
||||
].join('\u0000');
|
||||
if (
|
||||
metadataContextKeyRef.current === nextContextKey
|
||||
&& metadataContextConnectionConfigRef.current === currentConnectionConfig
|
||||
&& metadataContextConnectionConfigRef.current === connectionConfig
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
metadataContextKeyRef.current = nextContextKey;
|
||||
metadataContextConnectionConfigRef.current = currentConnectionConfig;
|
||||
metadataContextConnectionConfigRef.current = connectionConfig;
|
||||
metadataFetchKeyRef.current = '';
|
||||
aiContextMetadataWarmupRef.current = {};
|
||||
aiContextCacheRef.current = null;
|
||||
@@ -2183,11 +2187,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
synonymsRef.current = [];
|
||||
triggersRef.current = [];
|
||||
routinesRef.current = [];
|
||||
sequencesRef.current = [];
|
||||
packagesRef.current = [];
|
||||
columnsCacheRef.current = {};
|
||||
if (isActive) {
|
||||
resetSharedQueryEditorMetadata();
|
||||
}
|
||||
}, [currentConnectionConfig, currentConnectionId, currentDb, isActive]);
|
||||
return true;
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
resetMetadataForContext(currentConnectionId, currentDb, currentConnectionConfig);
|
||||
}, [currentConnectionConfig, currentConnectionId, currentDb, resetMetadataForContext]);
|
||||
|
||||
const currentSavedQuery = useMemo(() => {
|
||||
const savedId = String(tab.savedQueryId || '').trim();
|
||||
@@ -2688,6 +2699,45 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
connectionsRef.current = connections;
|
||||
}, [connections]);
|
||||
|
||||
const handleDatabaseChange = useCallback((dbName: string) => {
|
||||
const nextDbName = String(dbName || '');
|
||||
const connectionId = String(currentConnectionIdRef.current || currentConnectionId || '').trim();
|
||||
currentDbRef.current = nextDbName;
|
||||
|
||||
if (isActive) {
|
||||
const activeConnectionConfig = connections.find(
|
||||
(connection) => connection.id === connectionId,
|
||||
)?.config ?? null;
|
||||
const metadataContextChanged = resetMetadataForContext(
|
||||
connectionId,
|
||||
nextDbName,
|
||||
activeConnectionConfig,
|
||||
);
|
||||
const nextSharedMetadataContextKey = `${tab.id}\u0000${connectionId}\u0000${nextDbName}`;
|
||||
if (
|
||||
!metadataContextChanged
|
||||
&& (
|
||||
sharedQueryEditorMetadataContextKey !== nextSharedMetadataContextKey
|
||||
|| sharedQueryEditorMetadataConnectionConfig !== activeConnectionConfig
|
||||
)
|
||||
) {
|
||||
resetSharedQueryEditorMetadata();
|
||||
}
|
||||
sharedQueryEditorMetadataContextKey = nextSharedMetadataContextKey;
|
||||
sharedQueryEditorMetadataConnectionConfig = activeConnectionConfig;
|
||||
sharedCurrentDb = nextDbName;
|
||||
sharedCurrentConnectionId = connectionId;
|
||||
sharedConnections = connections;
|
||||
sharedVisibleDbs = visibleDbsRef.current;
|
||||
sharedActiveEditorModelUri = String(editorRef.current?.getModel?.()?.uri?.toString?.() || '');
|
||||
}
|
||||
|
||||
if (connectionId) {
|
||||
setActiveContext({ connectionId, dbName: nextDbName });
|
||||
}
|
||||
setCurrentDb(nextDbName);
|
||||
}, [connections, currentConnectionId, isActive, resetMetadataForContext, setActiveContext, tab.id]);
|
||||
|
||||
const refreshObjectDecorations = useCallback((maxTextLength = QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH) => {
|
||||
const editor = editorRef.current;
|
||||
const monaco = monacoRef.current;
|
||||
@@ -3441,15 +3491,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
|
||||
setDbList(dbs);
|
||||
if (!currentDbRef.current) {
|
||||
const configuredDb = String(conn.config.database || '').trim();
|
||||
const fallbackDb = dbs.find((db: string) => String(db || '').toLowerCase() !== 'information_schema') || dbs[0] || '';
|
||||
const nextDb = configuredDb && dbs.includes(configuredDb) ? configuredDb : fallbackDb;
|
||||
if (nextDb) {
|
||||
currentDbRef.current = nextDb;
|
||||
setCurrentDb(nextDb);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
visibleDbsRef.current = [];
|
||||
if (isActive) {
|
||||
@@ -10573,7 +10614,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
setCurrentConnectionId(val);
|
||||
setCurrentDb('');
|
||||
}}
|
||||
onDatabaseChange={setCurrentDb}
|
||||
onDatabaseChange={handleDatabaseChange}
|
||||
onMaxRowsChange={(maxRows) => setQueryOptions({ maxRows })}
|
||||
onCommitModeChange={(mode) => setSqlEditorTransactionOptions(
|
||||
mode === 'auto'
|
||||
|
||||
47
frontend/src/utils/newQueryContext.test.ts
Normal file
47
frontend/src/utils/newQueryContext.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveNewQueryContext } from './newQueryContext';
|
||||
|
||||
describe('resolveNewQueryContext', () => {
|
||||
const validConnectionIds = new Set(['conn-a', 'conn-b']);
|
||||
|
||||
it('prefers the explicitly selected sidebar database over the active query tab', () => {
|
||||
expect(resolveNewQueryContext({
|
||||
sidebarContext: { connectionId: 'conn-b', dbName: 'database_b' },
|
||||
activeTab: { connectionId: 'conn-a', dbName: 'database_a' },
|
||||
validConnectionIds,
|
||||
})).toEqual({ connectionId: 'conn-b', dbName: 'database_b' });
|
||||
});
|
||||
|
||||
it('keeps a valid connection-level sidebar selection instead of borrowing the tab database', () => {
|
||||
expect(resolveNewQueryContext({
|
||||
sidebarContext: { connectionId: 'conn-b', dbName: '' },
|
||||
activeTab: { connectionId: 'conn-a', dbName: 'database_a' },
|
||||
validConnectionIds,
|
||||
})).toEqual({ connectionId: 'conn-b', dbName: '' });
|
||||
});
|
||||
|
||||
it('falls back to the active tab when the sidebar context is unavailable or stale', () => {
|
||||
expect(resolveNewQueryContext({
|
||||
sidebarContext: { connectionId: 'removed-connection', dbName: 'old_db' },
|
||||
activeTab: { connectionId: 'conn-a', dbName: 'database_a' },
|
||||
validConnectionIds,
|
||||
})).toEqual({ connectionId: 'conn-a', dbName: 'database_a' });
|
||||
});
|
||||
|
||||
it('preserves database identifiers exactly as stored', () => {
|
||||
expect(resolveNewQueryContext({
|
||||
sidebarContext: { connectionId: 'conn-b', dbName: ' database b ' },
|
||||
activeTab: null,
|
||||
validConnectionIds,
|
||||
})).toEqual({ connectionId: 'conn-b', dbName: ' database b ' });
|
||||
});
|
||||
|
||||
it('returns an unbound query context when neither source points to a valid connection', () => {
|
||||
expect(resolveNewQueryContext({
|
||||
sidebarContext: null,
|
||||
activeTab: { connectionId: 'removed-connection', dbName: 'old_db' },
|
||||
validConnectionIds,
|
||||
})).toEqual({ connectionId: '', dbName: '' });
|
||||
});
|
||||
});
|
||||
37
frontend/src/utils/newQueryContext.ts
Normal file
37
frontend/src/utils/newQueryContext.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface NewQueryContextLike {
|
||||
connectionId?: unknown;
|
||||
dbName?: unknown;
|
||||
}
|
||||
|
||||
export interface NewQueryContext {
|
||||
connectionId: string;
|
||||
dbName: string;
|
||||
}
|
||||
|
||||
const normalizeValidContext = (
|
||||
context: NewQueryContextLike | null | undefined,
|
||||
validConnectionIds: ReadonlySet<string>,
|
||||
): NewQueryContext | null => {
|
||||
const connectionId = String(context?.connectionId || '').trim();
|
||||
if (!connectionId || !validConnectionIds.has(connectionId)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
connectionId,
|
||||
dbName: String(context?.dbName ?? ''),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveNewQueryContext = ({
|
||||
sidebarContext,
|
||||
activeTab,
|
||||
validConnectionIds,
|
||||
}: {
|
||||
sidebarContext?: NewQueryContextLike | null;
|
||||
activeTab?: NewQueryContextLike | null;
|
||||
validConnectionIds: ReadonlySet<string>;
|
||||
}): NewQueryContext => (
|
||||
normalizeValidContext(sidebarContext, validConnectionIds)
|
||||
|| normalizeValidContext(activeTab, validConnectionIds)
|
||||
|| { connectionId: '', dbName: '' }
|
||||
);
|
||||
Reference in New Issue
Block a user