diff --git a/agents/drivers/oracle-go/main.go b/agents/drivers/oracle-go/main.go index fb0c8d538a..c272c584c6 100644 --- a/agents/drivers/oracle-go/main.go +++ b/agents/drivers/oracle-go/main.go @@ -49,7 +49,7 @@ WHERE username IS NOT NULL AND username NOT IN ( 'SYS','SYSTEM','SYSMAN','DBSNMP','SYSBACKUP','SYSDG','SYSKM','SYSRAC','OUTLN', 'AUDSYS','LBACSYS','DVF','DVSYS','APPQOSSYS','CTXSYS','MDSYS','MDDATA', - 'ORDSYS','ORDDATA','ORDPLUGINS','XDB','ANONYMOUS','DIP','EXFSYS', + 'ORDSYS','ORDDATA','ORDPLUGINS','XDB','ANONYMOUS','EXFSYS', 'GSMADMIN_INTERNAL','GSMCATUSER','GSMROOTUSER','GSMUSER','OJVMSYS','OLAPSYS', 'ORACLE_OCM','SI_INFORMTN_SCHEMA','WMSYS','XS$NULL','DBSFWUSER', 'REMOTE_SCHEDULER_AGENT','PDBADMIN','DGPDB_INT','OPS$ORACLE', @@ -1135,11 +1135,17 @@ func (s *server) currentSchema() (string, error) { } func (s *server) normalizeSchema(schema string) (string, error) { - schema = strings.TrimSpace(schema) - if schema == "" { - return s.currentSchema() + return resolveOracleSchema(schema, s.currentSchema, s.sessionUser) +} + +func resolveOracleSchema(schema string, currentSchema, sessionUser func() (string, error)) (string, error) { + if schema = strings.TrimSpace(schema); schema != "" { + return strings.ToUpper(schema), nil } - return strings.ToUpper(schema), nil + if current, err := currentSchema(); err == nil && strings.TrimSpace(current) != "" { + return strings.ToUpper(strings.TrimSpace(current)), nil + } + return sessionUser() } func (s *server) sessionUser() (string, error) { diff --git a/agents/drivers/oracle-go/main_test.go b/agents/drivers/oracle-go/main_test.go index 305f92cba5..63e532ff48 100644 --- a/agents/drivers/oracle-go/main_test.go +++ b/agents/drivers/oracle-go/main_test.go @@ -652,6 +652,9 @@ func TestListDatabasesSQLUsesUserDictionaryInsteadOfObjectDictionary(t *testing. if strings.Contains(sqlText, "ALL_TABLES") || strings.Contains(sqlText, "ALL_VIEWS") { t.Fatalf("schema listing should not scan object dictionaries, got: %s", oracleListDatabasesSQL) } + if strings.Contains(sqlText, "'DIP'") { + t.Fatalf("schema listing should not hide an existing user named DIP, got: %s", oracleListDatabasesSQL) + } } func TestListDatabasesSQLCanApplyVisibleSchemaFilter(t *testing.T) { @@ -672,6 +675,41 @@ func TestListDatabasesSQLCanApplyVisibleSchemaFilter(t *testing.T) { } } +func TestResolveOracleSchemaPrefersCurrentSchemaOverSessionUser(t *testing.T) { + currentCalls := 0 + sessionUserCalls := 0 + schema, err := resolveOracleSchema( + "", + func() (string, error) { + currentCalls++ + return "REPORTING", nil + }, + func() (string, error) { + sessionUserCalls++ + return "APP", nil + }, + ) + + if err != nil || schema != "REPORTING" { + t.Fatalf("resolved schema = %q, err = %v; want REPORTING", schema, err) + } + if currentCalls != 1 || sessionUserCalls != 0 { + t.Fatalf("unexpected resolver calls: current=%d session_user=%d", currentCalls, sessionUserCalls) + } +} + +func TestResolveOracleSchemaFallsBackToSessionUser(t *testing.T) { + schema, err := resolveOracleSchema( + "", + func() (string, error) { return "", errors.New("CURRENT_SCHEMA unavailable") }, + func() (string, error) { return "APP", nil }, + ) + + if err != nil || schema != "APP" { + t.Fatalf("resolved schema = %q, err = %v; want APP", schema, err) + } +} + func TestListTablesSQLUsesSplitDictionaryQuery(t *testing.T) { sqlText := strings.ToUpper(oracleListTablesSQL) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index df31062816..227177cadd 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -43,6 +43,7 @@ import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion"; import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; +import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession"; import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; import { @@ -95,6 +96,8 @@ const props = defineProps<{ connectionId?: string; database?: string; schema?: string; + clientSessionId?: string; + completionContextVersion?: number; databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver"; syntaxDialect?: "mysql" | "postgres" | "sqlserver"; @@ -350,6 +353,38 @@ const cachedInsertValueHintColumnsByTable = new Map(); const cachedForeignKeysByTable = new Map(); const loadedColumnsByTable = new Set(); +function usesOracleSessionCompletionColumns(schema?: string | null): boolean { + return shouldUseOracleSessionCompletionColumns({ + databaseType: props.databaseType, + selectedSchema: props.schema, + referenceSchema: schema, + clientSessionId: props.clientSessionId, + }); +} + +function completionColumnRequestContext() { + return { + clientSessionId: props.clientSessionId, + version: props.completionContextVersion, + }; +} + +async function listCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string) { + const requestedVersion = props.completionContextVersion; + const sessionScoped = usesOracleSessionCompletionColumns(schema); + const columns = await connectionStore.listCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext()); + if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); + return columns; +} + +async function refreshCompletionColumnsForEditor(connectionId: string, database: string, table: string, schema?: string) { + const requestedVersion = props.completionContextVersion; + const sessionScoped = usesOracleSessionCompletionColumns(schema); + const columns = await connectionStore.refreshCompletionColumns(connectionId, database, table, schema, completionColumnRequestContext()); + if (sessionScoped && requestedVersion !== props.completionContextVersion) throw new Error("Stale Oracle completion context"); + return columns; +} + const zoomCommitScheduler = createEditorZoomCommitScheduler((fontSize) => { if (settingsStore.editorSettings.fontSize === fontSize) return; settingsStore.updateEditorSettings({ fontSize }); @@ -1378,7 +1413,7 @@ function requestInsertValueHintTableColumns(table: string, schema?: string, data cachedInsertValueHintColumnsByTable.set(cacheKey, insertValueHintColumnNames(databaseType, columns)); return; } - const columns = await connectionStore.listCompletionColumns(connectionId, target.database, table, target.schema); + const columns = await listCompletionColumnsForEditor(connectionId, target.database, table, target.schema); cachedColumnsByTable.set(cacheKey, columns); }; void loadColumns() @@ -1458,7 +1493,7 @@ async function ensureColumnsForTable(table: { name: string; schema?: string | nu if (!props.connectionId || props.database == null) return false; const target = completionMetadataTarget(table); if (!target) return false; - const columns = await connectionStore.listCompletionColumns(props.connectionId, target.database, table.name, target.schema); + const columns = await listCompletionColumnsForEditor(props.connectionId, target.database, table.name, target.schema); cachedColumnsByTable.set(cacheKey, columns); loadedColumnsByTable.add(cacheKey.toLowerCase()); return true; @@ -1782,6 +1817,10 @@ async function enrichSemanticDiagnosticTables(tables: SqlTableReference[]): Prom enriched.push(table); continue; } + if (usesOracleSessionCompletionColumns(table.schema)) { + enriched.push(table); + continue; + } try { const match = await findExactSemanticDiagnosticTable(table); if (!match) missingTables.add(tableReferenceKey(table)); @@ -2439,7 +2478,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType(); if (completionContext.insertTable) { const insertSchema = completionContext.insertSchema ?? props.schema; - const insertColumns = connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, completionContext.insertTable, insertSchema); + const insertColumns = usesOracleSessionCompletionColumns(insertSchema) ? [] : connectionStore.lookupLocalCompletionColumns(props.connectionId, props.database, completionContext.insertTable, insertSchema); if (insertColumns.length > 0) { columnsByTable.set(insertSchema ? `${insertSchema}.${completionContext.insertTable}` : completionContext.insertTable, insertColumns); } @@ -2453,7 +2492,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(cacheKey, localColumns); } @@ -2482,7 +2521,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType 0) { columnsByTable.set(cacheKey, localColumns); } @@ -2558,8 +2597,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { const insertSchema = completionContext.insertSchema ?? props.schema; cachedColumnsByTable.set(insertSchema ? `${insertSchema}.${insertTable}` : insertTable, columns); @@ -2571,8 +2609,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { if (columns.length > 0) cachedColumnsByTable.set(qualifiedColumnCacheKey, columns); }) @@ -2588,8 +2625,7 @@ function scheduleCompletionMetadataRefresh(completionContext: ReturnType { if (columns.length > 0) cachedColumnsByTable.set(cacheKey, columns); }) @@ -2692,7 +2728,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext let insertColumnsByTable = new Map(); if (completionContext.insertTable) { try { - const insertCols = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, completionContext.insertTable, completionContext.insertSchema ?? props.schema); + const insertCols = await listCompletionColumnsForEditor(props.connectionId!, props.database!, completionContext.insertTable, completionContext.insertSchema ?? props.schema); if (epoch !== completionEpoch) return null; if (insertCols.length > 0) { const insertSchema = completionContext.insertSchema ?? props.schema; @@ -2776,6 +2812,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext // Collect referenced tables — enrich with schema from filtered table lookup let refs = completionContext.referencedTables.map((rt) => { + if (usesOracleSessionCompletionColumns(rt.schema)) return rt; if (!rt.schema) { const cached = tables.find((t) => t.name.toLowerCase() === rt.name.toLowerCase()); if (cached && cached.schema) { @@ -2784,12 +2821,13 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext } return rt; }); - const unresolvedRefs = refs.filter((rt) => !rt.schema && !rt.columns && !isVirtualCompletionTableReference(rt)); + const unresolvedRefs = refs.filter((rt) => !usesOracleSessionCompletionColumns(rt.schema) && !rt.schema && !rt.columns && !isVirtualCompletionTableReference(rt)); if (!localOnlyMetadata && unresolvedRefs.length > 0) { const lookupGroups = await Promise.all(unresolvedRefs.map((rt) => connectionStore.listCompletionTables(props.connectionId!, props.database!, rt.name, 20, props.schema, false, props.schema))); if (epoch !== completionEpoch) return null; const lookupTables = lookupGroups.flat(); refs = refs.map((rt) => { + if (usesOracleSessionCompletionColumns(rt.schema)) return rt; if (rt.schema || rt.columns) return rt; const matched = lookupTables.find((table) => table.name.toLowerCase() === rt.name.toLowerCase()); return matched?.schema ? { ...rt, schema: matched.schema } : rt; @@ -2830,7 +2868,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext try { const target = completionMetadataTarget(refTable); if (!target) return; - const columns = await connectionStore.listCompletionColumns(props.connectionId!, target.database, refTable.name, target.schema); + const columns = await listCompletionColumnsForEditor(props.connectionId!, target.database, refTable.name, target.schema); if (epoch !== completionEpoch) return; if (columns.length === 0) return; cachedColumnsByTable.set(cacheKey, columns); @@ -3639,6 +3677,7 @@ onMounted(async () => { let referencedTables: Array> = context.referencedTables; // Enrich referenced tables with schema from cachedTables referencedTables = referencedTables.map((rt) => { + if (usesOracleSessionCompletionColumns(rt.schema)) return rt; const cached = cachedTables.find((ct) => ct.name.toLowerCase() === rt.name.toLowerCase() && (!rt.schema || !ct.schema || ct.schema.toLowerCase() === rt.schema.toLowerCase())); if (!cached) return rt; return { @@ -3682,7 +3721,8 @@ onMounted(async () => { let cols = cachedColumnsByTable.get(cacheKey); if (!cols) { try { - cols = await connectionStore.listCompletionColumns(props.connectionId!, props.database!, refTable.name, refTable.schema ?? props.schema); + const columnSchema = refTable.schema ?? props.schema; + cols = await listCompletionColumnsForEditor(props.connectionId!, props.database!, refTable.name, columnSchema); cachedColumnsByTable.set(cacheKey, cols); } catch { continue; @@ -3821,6 +3861,13 @@ watch( }, ); +watch([() => props.clientSessionId, () => props.completionContextVersion], () => { + completionEpoch++; + refreshCompletionCache(); + setSemanticDiagnostics([]); + scheduleSemanticDiagnostics(); +}); + watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect], () => { executableStatementRangeCache = null; if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension || !sqlSignatureComp || !buildSqlSignatureExtension) return; diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 8c25882033..0b784a09ee 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -814,6 +814,8 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand :connection-id="activeTab.connectionId" :database="activeTab.database" :schema="activeTab.schema" + :client-session-id="activeTab.id" + :completion-context-version="activeTab.completionContextVersion" :database-type="activeEffectiveDatabaseType" :dialect="editorDialect" :syntax-dialect="editorSyntaxDialect" diff --git a/apps/desktop/src/lib/__tests__/sql/oracleCompletionSession.spec.ts b/apps/desktop/src/lib/__tests__/sql/oracleCompletionSession.spec.ts new file mode 100644 index 0000000000..bf8a82a5e5 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/oracleCompletionSession.spec.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { usesOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession"; + +const queryEditorSource = readFileSync(new URL("../../../components/editor/QueryEditor.vue", import.meta.url), "utf8"); +const contentAreaSource = readFileSync(new URL("../../../components/layout/ContentArea.vue", import.meta.url), "utf8"); + +describe("Oracle session-scoped completion", () => { + it("uses the tab session only for unqualified references without a selected schema", () => { + expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a" })).toBe(true); + expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a", referenceSchema: "REPORTING" })).toBe(false); + expect(usesOracleSessionCompletionColumns({ databaseType: "oracle", clientSessionId: "tab-a", selectedSchema: "REPORTING" })).toBe(false); + expect(usesOracleSessionCompletionColumns({ databaseType: "postgres", clientSessionId: "tab-a" })).toBe(false); + expect(usesOracleSessionCompletionColumns({ databaseType: "oracle" })).toBe(false); + }); + + it("passes tab context into QueryEditor and clears local caches when the tab or context changes", () => { + expect(contentAreaSource).toContain(':client-session-id="activeTab.id"'); + expect(contentAreaSource).toContain(':completion-context-version="activeTab.completionContextVersion"'); + expect(queryEditorSource).toMatch(/watch\(\s*\[\(\) => props\.clientSessionId, \(\) => props\.completionContextVersion\][\s\S]*?refreshCompletionCache\(\)/); + }); +}); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index ffa7c61574..82231e22d5 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -640,8 +640,8 @@ export async function getObjectSource(connectionId: string, database: string, sc return get(`/api/schema/object-source?${qs({ connection_id: connectionId, database, schema, table: name, object_type: objectType, signature })}`); } -export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog })}`); +export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise { + return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table, catalog, client_session_id: clientSessionId })}`); } export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 0a98f75c83..e35a0c66a9 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -801,8 +801,8 @@ export async function listSchemaInfos(connectionId: string, database: string): P return invoke("list_schema_infos", { connectionId, database }); } -export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string): Promise { - return invoke("get_columns", { connectionId, database, schema, table, catalog }); +export async function getColumns(connectionId: string, database: string, schema: string, table: string, catalog?: string, clientSessionId?: string): Promise { + return invoke("get_columns", { connectionId, database, schema, table, catalog, clientSessionId }); } export async function getSqlServerColumnMetadata(connectionId: string, database: string, schema: string, table: string): Promise { diff --git a/apps/desktop/src/lib/database/visibleDatabases.ts b/apps/desktop/src/lib/database/visibleDatabases.ts index 26e48cd2f3..1b9cfa9cc2 100644 --- a/apps/desktop/src/lib/database/visibleDatabases.ts +++ b/apps/desktop/src/lib/database/visibleDatabases.ts @@ -26,7 +26,6 @@ const SYSTEM_DATABASE_RULES: Partial>> "audsys", "ctxsys", "dbsnmp", - "dip", "dvf", "dvsys", "exfsys", diff --git a/apps/desktop/src/lib/sql/oracleCompletionSession.ts b/apps/desktop/src/lib/sql/oracleCompletionSession.ts new file mode 100644 index 0000000000..9477fbfb82 --- /dev/null +++ b/apps/desktop/src/lib/sql/oracleCompletionSession.ts @@ -0,0 +1,5 @@ +import type { DatabaseType } from "@/types/database"; + +export function usesOracleSessionCompletionColumns(options: { databaseType?: DatabaseType; selectedSchema?: string; referenceSchema?: string | null; clientSessionId?: string }): boolean { + return options.databaseType === "oracle" && !options.selectedSchema && !options.referenceSchema && !!options.clientSessionId; +} diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index e72e5ba401..0b12f5787b 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -232,6 +232,95 @@ describe("connectionStore completion assistant", () => { ]); }); + it("lets Oracle resolve CURRENT_SCHEMA for unqualified column completion", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [], + incomplete: false, + fallback_used: false, + }); + const getColumns = vi.fn().mockResolvedValue([{ name: "REPORT_ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: true, extra: null, comment: null }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + const columns = await store.listCompletionColumns("oracle-1", "ORCL", "ORDERS", undefined, { clientSessionId: "tab-a", version: 0 }); + + expect(completionAssistantSearch).not.toHaveBeenCalled(); + expect(getColumns).toHaveBeenCalledWith("oracle-1", "ORCL", "", "ORDERS", undefined, "tab-a"); + expect(columns).toEqual([expect.objectContaining({ name: "REPORT_ID", table: "ORDERS", schema: undefined, dataType: "NUMBER" })]); + expect(store.lookupLocalCompletionColumns("oracle-1", "ORCL", "ORDERS")).toEqual([]); + }); + + it("isolates Oracle CURRENT_SCHEMA column caches by tab and context version", async () => { + const completionAssistantSearch = vi.fn(); + const getColumns = vi + .fn() + .mockResolvedValueOnce([{ name: "APP_ID", data_type: "NUMBER", is_nullable: false }]) + .mockResolvedValueOnce([{ name: "REPORT_ID", data_type: "NUMBER", is_nullable: false }]) + .mockResolvedValueOnce([{ name: "SYSTEM_ID", data_type: "NUMBER", is_nullable: false }]); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + const app = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-a", version: 0 }); + const cachedApp = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-a", version: 0 }); + const reporting = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-a", version: 1 }); + const independentTab = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", undefined, { clientSessionId: "tab-b", version: 0 }); + + expect(app.map((column) => column.name)).toEqual(["APP_ID"]); + expect(cachedApp.map((column) => column.name)).toEqual(["APP_ID"]); + expect(reporting.map((column) => column.name)).toEqual(["REPORT_ID"]); + expect(independentTab.map((column) => column.name)).toEqual(["SYSTEM_ID"]); + expect(getColumns).toHaveBeenCalledTimes(3); + expect(getColumns.mock.calls.map((call) => call[5])).toEqual(["tab-a", "tab-a", "tab-b"]); + expect(completionAssistantSearch).not.toHaveBeenCalled(); + }); + + it("keeps explicit Oracle schema completion on the shared assistant path", async () => { + const completionAssistantSearch = vi.fn().mockResolvedValue({ + candidates: [{ name: "EXPLICIT_ID", kind: "column", schema: "REPORTING", parent_schema: "REPORTING", parent_name: "SHARED_TABLE", data_type: "NUMBER" }], + incomplete: false, + fallback_used: false, + }); + const getColumns = vi.fn(); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns, + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [oracleConnection()]; + store.connectedIds.add("oracle-1"); + + const columns = await store.listCompletionColumns("oracle-1", "ORCL", "SHARED_TABLE", "REPORTING", { clientSessionId: "tab-a", version: 2 }); + + expect(completionAssistantSearch).toHaveBeenCalledWith(expect.objectContaining({ schema: "REPORTING", parent_schema: "REPORTING", parent_name: "SHARED_TABLE" })); + expect(getColumns).not.toHaveBeenCalled(); + expect(columns).toEqual([expect.objectContaining({ name: "EXPLICIT_ID", schema: "REPORTING" })]); + }); + it("maps Oracle package members without scanning every schema", async () => { const completionAssistantSearch = vi.fn().mockResolvedValue({ candidates: [{ name: "CALCULATE_BONUS", kind: "function", schema: "HR", parent_schema: "HR", parent_name: "PAYROLL", data_type: "FUNCTION" }], diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index b4d92d2b41..a8370e7d28 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -83,6 +83,51 @@ describe("queryStore multi-statement errors", () => { expect(tab.result?.columns).toEqual(["Error"]); }); + it("invalidates only the executing Oracle tab after successful CURRENT_SCHEMA changes", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "oracle-1", + name: "Oracle", + db_type: "oracle", + database: "ORCL", + query_timeout_secs: 30, + }); + mocks.executeMulti + .mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]) + .mockResolvedValueOnce([{ columns: ["Error"], rows: [["schema missing"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }]) + .mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabA = store.createTab("oracle-1", "ORCL", "Tab A"); + const tabB = store.createTab("oracle-1", "ORCL", "Tab B"); + + await store.executeTabSql(tabA, "ALTER SESSION SET CURRENT_SCHEMA = REPORTING"); + expect(store.tabs.find((tab) => tab.id === tabA)?.completionContextVersion).toBe(1); + expect(store.tabs.find((tab) => tab.id === tabB)?.completionContextVersion).toBeUndefined(); + + await store.executeTabSql(tabA, "/* retry */ ALTER SESSION SET CURRENT_SCHEMA = MISSING"); + expect(store.tabs.find((tab) => tab.id === tabA)?.completionContextVersion).toBe(1); + + await store.executeTabSql(tabA, "-- switch back\nALTER SESSION SET CURRENT_SCHEMA = APP"); + expect(store.tabs.find((tab) => tab.id === tabA)?.completionContextVersion).toBe(2); + expect(mocks.executeMulti.mock.calls.map((call) => call[5]?.clientSessionId)).toEqual([tabA, tabA, tabA]); + }); + + it("invalidates Oracle completion metadata when clearing a tab schema resets its session", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "oracle-1", + name: "Oracle", + db_type: "oracle", + database: "ORCL", + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("oracle-1", "ORCL", "Oracle", "query", "REPORTING"); + + store.updateSchema(tabId, undefined); + + expect(store.tabs.find((tab) => tab.id === tabId)?.completionContextVersion).toBe(1); + }); + it("preserves the selected statement's absolute editor range", async () => { mocks.executeMulti.mockResolvedValue([{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 }]); const { useQueryStore } = await import("@/stores/queryStore"); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 2d50ff1c40..ea56e60bdb 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2281,7 +2281,7 @@ export const useConnectionStore = defineStore("connection", () => { } else if (config && connectionUsesVisibleSchemaFilter(config)) { const schemaFilterConfig = config; const effectiveDb = schemaFilterConfig.database || ""; - const cacheKey = schemaCacheKey(connectionId, effectiveDb, "schemas"); + const cacheKey = schemaCacheKey(connectionId, effectiveDb, config.db_type === "oracle" ? "schemas-v2" : "schemas"); if (!options?.force) { const cached = await loadPersistedTreeChildren(node, cacheKey); if (cached.hit) { @@ -4781,39 +4781,45 @@ export const useConnectionStore = defineStore("connection", () => { return deduped; } - async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string): Promise { - if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(getConfig(connectionId)) && !schema) { + async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }): Promise { + const config = getConfig(connectionId); + const completionSchema = schema?.trim() || undefined; + const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema; + if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesOracleCurrentSchema) { return []; } - const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`; + const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : ""; + const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}${sessionCacheScope}`; if (!completionColumnsCache.value[cacheKey]) { await withCompletionInFlight( `${cacheKey}:columns`, async () => { await ensureConnected(connectionId); - try { - const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, schema); - if (assistantColumns.length > 0) { - completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({ - name: column.name, - data_type: column.dataType ?? "", - is_nullable: column.isNullable ?? true, - column_default: null, - is_primary_key: false, - extra: null, - comment: column.comment ?? null, - numeric_precision: null, - numeric_scale: null, - character_maximum_length: null, - })); - evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); - return; + if (!usesOracleCurrentSchema) { + try { + const assistantColumns = await listCompletionAssistantColumns(connectionId, database, table, completionSchema); + if (assistantColumns.length > 0) { + completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({ + name: column.name, + data_type: column.dataType ?? "", + is_nullable: column.isNullable ?? true, + column_default: null, + is_primary_key: false, + extra: null, + comment: column.comment ?? null, + numeric_precision: null, + numeric_scale: null, + character_maximum_length: null, + })); + evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); + return; + } + } catch { + // Fall back to the existing metadata path below. } - } catch { - // Fall back to the existing metadata path below. } - const querySchema = metadataQuerySchema(connectionId, database, schema); - completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table); + const querySchema = usesOracleCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema); + completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, table, undefined, usesOracleCurrentSchema ? context?.clientSessionId : undefined); evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX); }, { scope: completionLimiterScope(connectionId, database), kind: "columns" }, @@ -4823,12 +4829,12 @@ export const useConnectionStore = defineStore("connection", () => { const columns = completionColumnsCache.value[cacheKey].map((column) => ({ name: column.name, table, - schema, + schema: completionSchema, dataType: column.data_type, isNullable: column.is_nullable, comment: column.comment, })); - indexCompletionColumns(connectionId, database, table, schema, columns); + if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, table, schema, columns); return columns; } @@ -4874,8 +4880,8 @@ export const useConnectionStore = defineStore("connection", () => { return listCompletionDatabases(connectionId); } - function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string): Promise { - return listCompletionColumns(connectionId, database, table, schema); + function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number }): Promise { + return listCompletionColumns(connectionId, database, table, schema, context); } function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 5abd573afb..4d0e78ccd6 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -186,6 +186,25 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas return results; } +function isOracleCurrentSchemaStatement(statement: string | undefined): boolean { + let remaining = statement?.trimStart() ?? ""; + while (remaining) { + if (remaining.startsWith("--")) { + const newline = remaining.indexOf("\n"); + remaining = newline < 0 ? "" : remaining.slice(newline + 1).trimStart(); + continue; + } + if (remaining.startsWith("/*")) { + const end = remaining.indexOf("*/", 2); + if (end < 0) return false; + remaining = remaining.slice(end + 2).trimStart(); + continue; + } + break; + } + return /^ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\s*=/i.test(remaining); +} + function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string, databaseType?: DatabaseType, sourceRange?: { from: number; to: number }): QueryResult { result.sourceStatement = sourceStatement; if (sourceRange) { @@ -514,6 +533,7 @@ export const useQueryStore = defineStore("query", () => { } function queueTabSessionReset(tab: QueryTab) { + tab.completionContextVersion = (tab.completionContextVersion ?? 0) + 1; const previousReset = pendingTabSessionResets.get(tab.id); const reset = (async () => { if (previousReset) await previousReset; @@ -3412,6 +3432,7 @@ export const useQueryStore = defineStore("query", () => { executionPromise = api.executeMulti(tab.connectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions); } const results = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset); + const successfulOracleSchemaChanges = effectiveDbType === "oracle" ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0; if (hiddenPrimaryKeys.length > 0 && results.length === 1) { const hiddenIndexes = hiddenResultColumnIndexes(results[0]!.columns, hiddenPrimaryKeys); if (hiddenIndexes.length > 0) results[0]!.hidden_column_indexes = hiddenIndexes; @@ -3428,6 +3449,9 @@ export const useQueryStore = defineStore("query", () => { }); const current = tabs.value.find((t) => t.id === id); if (current?.executionId === executionId) { + if (successfulOracleSchemaChanges > 0) { + current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges; + } const activeGroupIndex = current.activeResultIndex; const activeGroupResults = current.results; const shouldAppendResult = !!options?.appendResult && !!current.result; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 8dd52b7076..cfc58a515e 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -831,6 +831,8 @@ export interface QueryTab { explainExecutionId?: string; /** Per-run connection session for explain flows that require session state. */ explainClientSessionId?: string; + /** Invalidates tab-scoped completion metadata after session context changes. */ + completionContextVersion?: number; mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard"; mqTenant?: string; mqInitialTab?: "topics"; diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 9a81c2edf5..0b8f5356f4 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -4268,6 +4268,20 @@ async fn retry_metadata_connection( state: &AppState, connection_id: &str, database: Option<&str>, + operation: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + retry_metadata_connection_for_session(state, connection_id, database, None, operation).await +} + +async fn retry_metadata_connection_for_session( + state: &AppState, + connection_id: &str, + database: Option<&str>, + client_session_id: Option<&str>, mut operation: F, ) -> Result where @@ -4277,7 +4291,7 @@ where let result = operation().await; match result { Err(error) if is_retryable_metadata_error(&error) => { - state.reconnect_pool(connection_id, database).await?; + state.reconnect_pool_for_session(connection_id, database, client_session_id).await?; operation().await } _ => result, @@ -4295,8 +4309,21 @@ pub async fn get_columns_core( schema: &str, table: &str, ) -> Result, String> { - retry_metadata_connection(state, connection_id, Some(database), || async { - let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; + get_columns_core_for_session(state, connection_id, database, schema, table, None).await +} + +pub async fn get_columns_core_for_session( + state: &AppState, + connection_id: &str, + database: &str, + schema: &str, + table: &str, + client_session_id: Option<&str>, +) -> Result, String> { + retry_metadata_connection_for_session(state, connection_id, Some(database), client_session_id, || async { + let pool_key = state + .get_or_create_pool_for_session(connection_id, Some(database), client_session_id) + .await?; #[cfg(feature = "duckdb-bundled")] let duckdb_attached_names = duckdb_attached_database_names(state, connection_id).await; let db_config = connection_config(state, connection_id).await; diff --git a/crates/dbx-web/src/routes/schema.rs b/crates/dbx-web/src/routes/schema.rs index 1597bf7ce0..491629a54a 100644 --- a/crates/dbx-web/src/routes/schema.rs +++ b/crates/dbx-web/src/routes/schema.rs @@ -22,6 +22,7 @@ pub struct SchemaQuery { pub signature: Option, pub object_types: Option, pub apply_visible_filter: Option, + pub client_session_id: Option, } pub async fn list_databases( @@ -300,9 +301,16 @@ pub async fn list_columns( .await .map_err(AppError)? } else { - dbx_core::schema::get_columns_core(&state.app, &q.connection_id, database, schema, table) - .await - .map_err(AppError)? + dbx_core::schema::get_columns_core_for_session( + &state.app, + &q.connection_id, + database, + schema, + table, + q.client_session_id.as_deref(), + ) + .await + .map_err(AppError)? }; Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?)) } diff --git a/packages/app-tests/connectionVisibleDatabases.test.ts b/packages/app-tests/connectionVisibleDatabases.test.ts index 211c732b6a..6df603521b 100644 --- a/packages/app-tests/connectionVisibleDatabases.test.ts +++ b/packages/app-tests/connectionVisibleDatabases.test.ts @@ -107,6 +107,10 @@ test("Dameng default SYSDBA user remains selectable", () => { assert.deepEqual(filterDatabaseNamesForConnection(["SYS", "SYSDBA", "SYSAUDITOR"], config({ db_type: "dameng" })), ["SYSDBA"]); }); +test("Oracle keeps an existing DIP user visible", () => { + assert.deepEqual(filterSchemaNamesForConnection(["DBX_TEST", "DIP", "SYSTEM"], config({ db_type: "oracle", database: "XE" }), "XE"), ["DBX_TEST", "DIP"]); +}); + test("visible database selection is stale when connection target changes", () => { const previous = config({ host: "db.internal", visible_databases: ["app"] }); assert.equal(visibleDatabaseSelectionIsStale(previous, config({ host: "db.internal" })), false); diff --git a/src-tauri/src/commands/schema.rs b/src-tauri/src/commands/schema.rs index 98eb7d9059..213dee96f0 100644 --- a/src-tauri/src/commands/schema.rs +++ b/src-tauri/src/commands/schema.rs @@ -292,12 +292,21 @@ pub async fn get_columns( schema: String, table: String, catalog: Option, + client_session_id: Option, ) -> Result, String> { if let Some(catalog) = external_doris_catalog(&state, &connection_id, catalog.as_deref()).await { return dbx_core::schema::get_doris_catalog_columns_core(&state, &connection_id, &catalog, &database, &table) .await; } - dbx_core::schema::get_columns_core(&state, &connection_id, &database, &schema, &table).await + dbx_core::schema::get_columns_core_for_session( + &state, + &connection_id, + &database, + &schema, + &table, + client_session_id.as_deref(), + ) + .await } #[tauri::command]