🐛 fix(query-editor): 修复新建查询数据库上下文错误

This commit is contained in:
AutumnNazi
2026-08-07 15:44:56 +08:00
parent fdb73e1f83
commit b765b22fce
5 changed files with 214 additions and 42 deletions

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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'

View 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: '' });
});
});

View 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: '' }
);