diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c0e7e8c..f135d0cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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]); diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 859a3421..659eebcd 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -18,6 +18,7 @@ import QueryEditor, { resolveQueryEditorNavigationDecorations, resolveQueryEditorNavigationTarget, } from './QueryEditor'; +import QueryEditorToolbar from './QueryEditorToolbar'; const mountedRenderers = new Set(); const create = (...args: Parameters): 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(); + renderer = create( + <> + + + , + ); }); 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; + 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(); + }); + 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; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ef822b77..96680c52 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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' diff --git a/frontend/src/utils/newQueryContext.test.ts b/frontend/src/utils/newQueryContext.test.ts new file mode 100644 index 00000000..a1a188bd --- /dev/null +++ b/frontend/src/utils/newQueryContext.test.ts @@ -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: '' }); + }); +}); diff --git a/frontend/src/utils/newQueryContext.ts b/frontend/src/utils/newQueryContext.ts new file mode 100644 index 00000000..7fc7c553 --- /dev/null +++ b/frontend/src/utils/newQueryContext.ts @@ -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, +): 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; +}): NewQueryContext => ( + normalizeValidContext(sidebarContext, validConnectionIds) + || normalizeValidContext(activeTab, validConnectionIds) + || { connectionId: '', dbName: '' } +);