diff --git a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts index 7740c09643..16b75a5c80 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.completion.spec.ts @@ -37,6 +37,18 @@ function oracleConnection(): ConnectionConfig { } as ConnectionConfig; } +function sapHanaConnection(): ConnectionConfig { + return { + ...postgresConnection(), + id: "hana-1", + name: "SAP HANA", + db_type: "saphana", + port: 30015, + username: "SYSTEM", + database: "", + } as ConnectionConfig; +} + function sqlServerConnection(): ConnectionConfig { return { ...postgresConnection(), @@ -296,6 +308,45 @@ describe("connectionStore completion assistant", () => { expect(completionAssistantSearch).not.toHaveBeenCalled(); }); + it("normalizes unquoted SAP HANA table and schema names before column lookup", async () => { + const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null; parent_schema?: string | null }) => ({ + candidates: [ + { + name: request.parent_name === "ACDOCA" ? "BELNR" : "MixedColumn", + kind: "column", + schema: request.parent_schema, + parent_schema: request.parent_schema, + parent_name: request.parent_name, + data_type: "NVARCHAR", + }, + ], + incomplete: false, + fallback_used: false, + })); + + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + completionAssistantSearch, + getColumns: vi.fn(), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + store.connections = [sapHanaConnection()]; + store.connectedIds.add("hana-1"); + + const unquoted = await store.listCompletionColumns("hana-1", "", "acdoca", "saphanadb", { tableQuoted: false, schemaQuoted: false }); + const quoted = await store.listCompletionColumns("hana-1", "", "MixedTable", "MixedSchema", { tableQuoted: true, schemaQuoted: true }); + + expect(completionAssistantSearch.mock.calls.map(([request]) => ({ schema: request.schema, parent_schema: request.parent_schema, parent_name: request.parent_name }))).toEqual([ + { schema: "SAPHANADB", parent_schema: "SAPHANADB", parent_name: "ACDOCA" }, + { schema: "MixedSchema", parent_schema: "MixedSchema", parent_name: "MixedTable" }, + ]); + expect(unquoted).toEqual([expect.objectContaining({ name: "BELNR", table: "ACDOCA", schema: "SAPHANADB" })]); + expect(quoted).toEqual([expect.objectContaining({ name: "MixedColumn", table: "MixedTable", schema: "MixedSchema" })]); + }); + it("keeps quoted and unquoted Oracle objects separate in the local column index", async () => { const completionAssistantSearch = vi.fn(async (request: { parent_name?: string | null }) => ({ candidates: [ diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index afa95e112b..87456a503c 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ closeQuerySession: vi.fn(), ensureConnected: vi.fn(), executeMulti: vi.fn(), + executeQuery: vi.fn(), getConnectionConfig: vi.fn(), prepareQueryPaginationExecutionPlan: vi.fn(), saveOpenTabsState: vi.fn(), @@ -20,6 +21,7 @@ vi.mock("@/lib/backend/api", () => ({ closeClientConnectionSession: mocks.closeClientConnectionSession, closeQuerySession: mocks.closeQuerySession, executeMulti: mocks.executeMulti, + executeQuery: mocks.executeQuery, prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan, saveOpenTabsState: mocks.saveOpenTabsState, })); @@ -174,6 +176,40 @@ describe("queryStore multi-statement errors", () => { expect(mocks.executeMulti.mock.calls.map((call) => call[5]?.clientSessionId)).toEqual([tabA, tabA, tabA]); }); + it("resolves the actual SAP HANA schema from the executing Agent session", async () => { + mocks.getConnectionConfig.mockReturnValue({ + id: "hana-1", + name: "SAP HANA", + db_type: "saphana", + database: "", + 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 }]); + mocks.executeQuery.mockResolvedValueOnce({ columns: ["CURRENT_SCHEMA"], rows: [["APP_SCHEMA"]], affected_rows: 0, execution_time_ms: 1 }).mockResolvedValueOnce({ columns: ["CURRENT_SCHEMA"], rows: [["MixedTargetSchema"]], affected_rows: 0, execution_time_ms: 1 }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabA = store.createTab("hana-1", "", "Tab A"); + const tabB = store.createTab("hana-1", "", "Tab B"); + + await store.executeTabSql(tabA, "SET SCHEMA app_schema_synonym"); + expect(store.tabs.find((tab) => tab.id === tabA)).toMatchObject({ schema: "APP_SCHEMA", completionContextVersion: 1 }); + expect(store.tabs.find((tab) => tab.id === tabB)?.schema).toBeUndefined(); + + await store.executeTabSql(tabA, 'SET SCHEMA "MissingSchema"'); + expect(store.tabs.find((tab) => tab.id === tabA)).toMatchObject({ schema: "APP_SCHEMA", completionContextVersion: 1 }); + + await store.executeTabSql(tabA, '/* switch */ SET SCHEMA "MixedSchema"'); + expect(store.tabs.find((tab) => tab.id === tabA)).toMatchObject({ schema: "MixedTargetSchema", completionContextVersion: 2 }); + expect(mocks.executeMulti.mock.calls.map((call) => call[5]?.clientSessionId)).toEqual([tabA, tabA, tabA]); + expect(mocks.executeQuery.mock.calls.map((call) => ({ sql: call[2], schema: call[3], clientSessionId: call[5]?.clientSessionId }))).toEqual([ + { sql: "SELECT CURRENT_SCHEMA FROM DUMMY", schema: undefined, clientSessionId: tabA }, + { sql: "SELECT CURRENT_SCHEMA FROM DUMMY", schema: undefined, clientSessionId: tabA }, + ]); + }); + it("invalidates Oracle completion metadata when clearing a tab schema resets its session", async () => { mocks.getConnectionConfig.mockReturnValue({ id: "oracle-1", diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 87248c32db..08833d1531 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -5748,9 +5748,10 @@ export const useConnectionStore = defineStore("connection", () => { async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise { const config = getConfig(connectionId); const oracleIdentifier = config?.db_type === "oracle"; - const completionTable = oracleIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table; + const uppercaseUnquotedIdentifier = oracleIdentifier || config?.db_type === "saphana"; + const completionTable = uppercaseUnquotedIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table; const rawCompletionSchema = schema?.trim() || undefined; - const completionSchema = oracleIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema; + const completionSchema = uppercaseUnquotedIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema; const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema; if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesOracleCurrentSchema) { return []; diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index a0586a0ec9..a6dc3cb72c 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -206,7 +206,7 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas return results; } -function isOracleCurrentSchemaStatement(statement: string | undefined): boolean { +function sqlStatementWithoutLeadingComments(statement: string | undefined): string { let remaining = statement?.trimStart() ?? ""; while (remaining) { if (remaining.startsWith("--")) { @@ -216,13 +216,26 @@ function isOracleCurrentSchemaStatement(statement: string | undefined): boolean } if (remaining.startsWith("/*")) { const end = remaining.indexOf("*/", 2); - if (end < 0) return false; + if (end < 0) return ""; remaining = remaining.slice(end + 2).trimStart(); continue; } break; } - return /^ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\s*=/i.test(remaining); + return remaining; +} + +function isOracleCurrentSchemaStatement(statement: string | undefined): boolean { + return /^ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\s*=/i.test(sqlStatementWithoutLeadingComments(statement)); +} + +function isSapHanaSetSchemaStatement(statement: string | undefined): boolean { + return /^SET\s+SCHEMA\s+(?:"(?:[^"]|"")*"|[A-Za-z_][\w$#]*)\s*;?\s*$/i.test(sqlStatementWithoutLeadingComments(statement)); +} + +function sapHanaCurrentSchemaFromResult(result: QueryResult): string | undefined { + const schema = result.rows[0]?.[0]; + return typeof schema === "string" && schema.trim() ? schema.trim() : undefined; } function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string, databaseType?: DatabaseType, sourceRange?: { from: number; to: number }): QueryResult { @@ -3689,6 +3702,7 @@ export const useQueryStore = defineStore("query", () => { } 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; + const successfulSapHanaSchemaChanges = effectiveDbType === "saphana" ? results.filter((result) => result.execution_error !== true && isSapHanaSetSchemaStatement(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; @@ -3703,12 +3717,28 @@ export const useQueryStore = defineStore("query", () => { columnCounts: results.map((result) => result.columns.length), elapsed: elapsed(), }); + let resolvedSapHanaSchema: string | undefined; + if (successfulSapHanaSchemaChanges > 0 && tabs.value.find((item) => item.id === id)?.executionId === executionId) { + try { + const schemaResult = await api.executeQuery(tab.connectionId, executionDatabase, "SELECT CURRENT_SCHEMA FROM DUMMY", undefined, executionId, { + clientSessionId: tabClientSessionId(tab), + timeoutSecs: queryTimeoutSecs, + }); + resolvedSapHanaSchema = sapHanaCurrentSchemaFromResult(schemaResult); + } catch (error) { + console.warn("[DBX] Failed to resolve SAP HANA CURRENT_SCHEMA", error); + } + } const current = tabs.value.find((t) => t.id === id); if (current?.executionId === executionId) { if (openInNewResultTab && current.isCancelling && restorePendingResultRun(current, executionId)) return false; if (successfulOracleSchemaChanges > 0) { current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulOracleSchemaChanges; } + if (resolvedSapHanaSchema) { + current.schema = resolvedSapHanaSchema; + current.completionContextVersion = (current.completionContextVersion ?? 0) + successfulSapHanaSchemaChanges; + } const activeGroupIndex = current.activeResultIndex; const activeGroupResults = current.results; const shouldAppendResult = !!options?.appendResult && !!current.result;