fix(explain): preserve backend error details

This commit is contained in:
t8y2
2026-07-18 00:11:21 +08:00
parent c40683431b
commit fa64952c08
5 changed files with 160 additions and 14 deletions
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: mocks.invoke,
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(),
}));
describe("getExplainInfo backend error propagation", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
it("preserves Tauri command errors", async () => {
const error = new Error("ORA-01031: insufficient privileges");
mocks.invoke.mockRejectedValue(error);
const { getExplainInfo } = await import("@/lib/backend/tauri");
await expect(getExplainInfo("oracle-1", "ORCL", "APP", "SELECT * FROM DUAL", "explain")).rejects.toBe(error);
});
it("preserves HTTP response errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
text: vi.fn().mockResolvedValue("Agent error: PLAN_TABLE is not accessible"),
}),
);
const { getExplainInfo } = await import("@/lib/backend/http");
await expect(getExplainInfo("oracle-1", "ORCL", "APP", "SELECT * FROM DUAL", "explain")).rejects.toThrow("Agent error: PLAN_TABLE is not accessible");
});
});
+2 -6
View File
@@ -810,12 +810,8 @@ export async function buildCreateUserSql(username: string, password: string, tab
}
export async function getExplainInfo(connectionId: string, database: string | undefined, schema: string | undefined, sql: string, mode: string): Promise<string | undefined> {
try {
const result = await post<string>("/api/query/get-explain-info", { connectionId, database, schema, sql, mode });
return result;
} catch {
return undefined;
}
// Match the Tauri path: transport and Agent failures must remain distinguishable from an empty plan.
return post<string>("/api/query/get-explain-info", { connectionId, database, schema, sql, mode });
}
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
+2 -7
View File
@@ -901,13 +901,8 @@ export async function buildCreateUserSql(username: string, password: string, tab
}
export async function getExplainInfo(connectionId: string, database: string | undefined, schema: string | undefined, sql: string, mode: string): Promise<string | undefined> {
try {
const result = await invoke<string>("get_explain_info", { connectionId, database, schema, sql, mode });
return result;
} catch (e: any) {
console.error("[getExplainInfo] invoke failed:", e?.message || e);
return undefined;
}
// Preserve Agent/driver errors so the explain view can show the actionable cause.
return invoke<string>("get_explain_info", { connectionId, database, schema, sql, mode });
}
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
@@ -0,0 +1,112 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
buildExplainSql: vi.fn(),
getExplainInfo: vi.fn(),
parseOracleExplainText: vi.fn(),
saveOpenTabsState: vi.fn(),
getConfig: vi.fn(),
}));
vi.mock("@/lib/diagram/explainPlan", () => ({
buildExplainSql: mocks.buildExplainSql,
parseExplainResult: vi.fn(),
parseDamengExplainText: vi.fn(),
parseOracleExplainText: mocks.parseOracleExplainText,
}));
vi.mock("@/lib/backend/api", () => ({
getExplainInfo: mocks.getExplainInfo,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
getConfig: mocks.getConfig,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { pageSize: 100, openTabsRestoreMode: "all", confirmUnsavedSqlClose: false },
}),
}));
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
}
describe("queryStore Oracle explain errors", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle" });
mocks.buildExplainSql.mockResolvedValue({ ok: true, sql: "EXPLAIN PLAN FOR SELECT * FROM DUAL" });
mocks.saveOpenTabsState.mockResolvedValue(undefined);
});
it("shows the real ORA error returned by the backend", async () => {
mocks.getExplainInfo.mockRejectedValue(new Error("ORA-01031: insufficient privileges"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP");
await store.explainTabSql(tabId, "SELECT * FROM DUAL", "oracle");
expect(store.tabs.find((tab) => tab.id === tabId)).toMatchObject({
isExplaining: false,
explainPlan: undefined,
explainError: "ORA-01031: insufficient privileges",
});
});
it("shows string Agent errors without replacing them", async () => {
mocks.getExplainInfo.mockRejectedValue("Agent error: DBMS_XPLAN execution failed");
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query");
await store.explainTabSql(tabId, "SELECT * FROM DUAL", "oracle");
expect(store.tabs.find((tab) => tab.id === tabId)?.explainError).toBe("Agent error: DBMS_XPLAN execution failed");
});
it("keeps the empty-plan message for a successful empty response", async () => {
mocks.getExplainInfo.mockResolvedValue("");
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query");
await store.explainTabSql(tabId, "SELECT * FROM DUAL", "oracle");
expect(store.tabs.find((tab) => tab.id === tabId)?.explainError).toBe("No explain plan returned");
expect(mocks.parseOracleExplainText).not.toHaveBeenCalled();
});
it("keeps successful Oracle explain behavior unchanged", async () => {
const parsedPlan = { databaseType: "oracle", rawText: "Plan hash value: 123", nodes: [] };
mocks.getExplainInfo.mockResolvedValue("Plan hash value: 123");
mocks.parseOracleExplainText.mockReturnValue(parsedPlan);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query");
await store.explainTabSql(tabId, "SELECT * FROM DUAL", "oracle");
expect(mocks.parseOracleExplainText).toHaveBeenCalledWith("Plan hash value: 123");
expect(store.tabs.find((tab) => tab.id === tabId)).toMatchObject({
explainPlan: parsedPlan,
explainSql: "EXPLAIN PLAN FOR SELECT * FROM DUAL",
explainError: undefined,
});
});
});
+3 -1
View File
@@ -57,6 +57,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { recordQueryCancellationLatency, resourceLifecycleDiagnostics } from "@/lib/diagnostics/resourceLifecycleDiagnostics";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { formatError } from "@/lib/backend/errorUtils";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
@@ -3598,7 +3599,8 @@ export const useQueryStore = defineStore("query", () => {
const current = tabs.value.find((t) => t.id === id);
if (current?.explainExecutionId === executionId) {
current.explainPlan = undefined;
current.explainError = String(e?.message || e);
// Backend rejections contain the real ORA/Agent diagnostic; only successful empty responses use the generic empty-plan message.
current.explainError = formatError(e);
}
} finally {
const current = tabs.value.find((t) => t.id === id);