fix(completion): resolve HANA session schema

This commit is contained in:
zipg authored and GitHub committed 2026-07-29 00:29:55 +08:00
1 parent 4cd14820b6
commit cadcd163f9
4 files changed
+123 -5

No files matched your search

@@ -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: [
@@ -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",
+3 -2
View File
@@ -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<SqlCompletionColumn[]> {
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 [];
+33 -3
View File
@@ -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;