fix(query): preserve structured backend errors
This commit is contained in:
1 parent
2e9dcdc26b
commit
bcafc4a474
58 files changed
+4182
-554
No files matched your search
@@ -14,29 +14,30 @@ function dataResult(columns: string[]): QueryResult {
|
||||
|
||||
describe("isNoSnapshotErrorResult", () => {
|
||||
it("matches the StarRocks Paimon no-snapshot error surfaced via executeTabSql", () => {
|
||||
const result = errorResult("Server error: `ERROR HY000 (1064): There is currently no snapshot.`");
|
||||
const result = { ...errorResult("Server error: `ERROR HY000 (1064): There is currently no snapshot.`"), execution_error: true as const };
|
||||
expect(isNoSnapshotErrorResult(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
const result = errorResult("there IS currently NO snapshot");
|
||||
const result = { ...errorResult("there IS currently NO snapshot"), execution_error: true as const };
|
||||
expect(isNoSnapshotErrorResult(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match unrelated query errors", () => {
|
||||
expect(isNoSnapshotErrorResult(errorResult("Unknown table 'tag_test.record_tag_t'"))).toBe(false);
|
||||
expect(isNoSnapshotErrorResult(errorResult("ERROR 1142: SELECT command denied"))).toBe(false);
|
||||
expect(isNoSnapshotErrorResult({ ...errorResult("Unknown table 'tag_test.record_tag_t'"), execution_error: true })).toBe(false);
|
||||
expect(isNoSnapshotErrorResult({ ...errorResult("ERROR 1142: SELECT command denied"), execution_error: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match successful data results", () => {
|
||||
expect(isNoSnapshotErrorResult(dataResult(["id", "name"]))).toBe(false);
|
||||
expect(isNoSnapshotErrorResult(dataResult(["Error"]))).toBe(false); // data column literally named Error, no rows
|
||||
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [["There is currently no snapshot."]] })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for missing or empty results", () => {
|
||||
expect(isNoSnapshotErrorResult(undefined)).toBe(false);
|
||||
expect(isNoSnapshotErrorResult(null)).toBe(false);
|
||||
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [] })).toBe(false);
|
||||
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), execution_error: true, rows: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,11 +60,17 @@ describe("SQL Server explain plan", () => {
|
||||
});
|
||||
|
||||
it("surfaces a SQL Server batch error instead of treating it as a plan", () => {
|
||||
expect(sqlServerExplainResult([result(["Error"], [["Invalid object name 'missing_table'"]])])).toEqual({
|
||||
expect(sqlServerExplainResult([{ ...result(["Error"], [["Invalid object name 'missing_table'"]]), execution_error: true }])).toEqual({
|
||||
error: "Invalid object name 'missing_table'",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mistake a successful Error column for a SQL Server batch failure", () => {
|
||||
expect(sqlServerExplainResult([result(["Error"], [["valid query value"]])])).toEqual({
|
||||
error: "SQL Server did not return ShowPlan XML",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed SHOWPLAN XML", () => {
|
||||
const malformed = result(["Microsoft SQL Server 2005 XML Showplan"], [["<ShowPlanXML><RelOp></ShowPlanXML>"]]);
|
||||
|
||||
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { connectionGroupDisplayName, executionSummaryItems, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
@@ -224,6 +224,18 @@ describe("query result source ranges", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("execution summary", () => {
|
||||
it("uses the explicit execution marker instead of the result column name", () => {
|
||||
const successfulAlias = { columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 };
|
||||
const markedFailure = { columns: ["Error"], rows: [["failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true as const };
|
||||
|
||||
expect(executionSummaryItems({ results: [successfulAlias, markedFailure] }).map(({ status, isError }) => ({ status, isError }))).toEqual([
|
||||
{ status: "success", isError: false },
|
||||
{ status: "error", isError: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statement execution markers", () => {
|
||||
it("renders a live marker for a single statement", () => {
|
||||
const sql = "SELECT 1";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { aiSkillForAction } from "@/lib/ai/aiSkills";
|
||||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
|
||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||
|
||||
@@ -674,12 +675,12 @@ function prioritizeSchemas(schemas: string[]): string[] {
|
||||
}
|
||||
|
||||
function extractLastError(result?: QueryResult): string | undefined {
|
||||
if (!result?.columns.includes("Error")) return undefined;
|
||||
if (!result || !isQueryExecutionErrorResult(result)) return undefined;
|
||||
return result.rows[0]?.[0] == null ? undefined : String(result.rows[0][0]);
|
||||
}
|
||||
|
||||
function formatResultPreview(result?: QueryResult): string | undefined {
|
||||
if (!result || result.columns.includes("Error") || !result.rows.length) return undefined;
|
||||
if (!result || isQueryExecutionErrorResult(result) || !result.rows.length) return undefined;
|
||||
const MAX_VALUE_CHARS = 200;
|
||||
const rows = result.rows.slice(0, 5).map((row) => {
|
||||
return result.columns
|
||||
|
||||
@@ -2,6 +2,87 @@
|
||||
* Utility functions for consistent error handling across the application.
|
||||
*/
|
||||
|
||||
export type BackendErrorParam = string | number | boolean;
|
||||
|
||||
export interface BackendError {
|
||||
version: 1;
|
||||
code: string;
|
||||
messageKey: string;
|
||||
messageParams: Record<string, BackendErrorParam>;
|
||||
source: "jdbcAgent" | "jdbcAgentLegacy" | "legacyBackend";
|
||||
operationOutcome: "not_started" | "unknown";
|
||||
detail?: string;
|
||||
diagnostics?: Record<string, unknown>;
|
||||
helpUrl?: string;
|
||||
}
|
||||
|
||||
function isBackendError(value: unknown): value is BackendError {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (
|
||||
candidate.version !== 1 ||
|
||||
typeof candidate.code !== "string" ||
|
||||
!/^DBX-[A-Z][A-Z0-9]*-\d{4}$/.test(candidate.code) ||
|
||||
typeof candidate.messageKey !== "string" ||
|
||||
!candidate.messageKey.startsWith("backendErrors.") ||
|
||||
!candidate.messageParams ||
|
||||
typeof candidate.messageParams !== "object" ||
|
||||
Array.isArray(candidate.messageParams) ||
|
||||
!["jdbcAgent", "jdbcAgentLegacy", "legacyBackend"].includes(String(candidate.source)) ||
|
||||
!["not_started", "unknown"].includes(String(candidate.operationOutcome))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.detail !== undefined && typeof candidate.detail !== "string") return false;
|
||||
return Object.values(candidate.messageParams).every((param) => typeof param === "string" || typeof param === "boolean" || (typeof param === "number" && Number.isFinite(param)));
|
||||
}
|
||||
|
||||
export function normalizeBackendError(error: unknown): BackendError | null {
|
||||
if (error instanceof BackendErrorException) return error.backendError;
|
||||
if (isBackendError(error)) return error;
|
||||
if (error && typeof error === "object" && "backendError" in error) {
|
||||
const backendError = (error as { backendError: unknown }).backendError;
|
||||
if (isBackendError(backendError)) return backendError;
|
||||
}
|
||||
if (error && typeof error === "object" && "error" in error) {
|
||||
const nested = (error as { error: unknown }).error;
|
||||
if (isBackendError(nested)) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export class BackendErrorException extends Error {
|
||||
readonly backendError: BackendError;
|
||||
|
||||
constructor(error: unknown) {
|
||||
const backendError = normalizeRawBackendError(error);
|
||||
const fallbackMessage = typeof error === "string" ? error : error instanceof Error ? error.message : "Backend request failed";
|
||||
super(backendError?.detail || fallbackMessage);
|
||||
this.name = "BackendErrorException";
|
||||
this.backendError = backendError ?? {
|
||||
version: 1,
|
||||
code: "DBX-LEGACY-0001",
|
||||
messageKey: "backendErrors.legacy",
|
||||
messageParams: {},
|
||||
source: "legacyBackend",
|
||||
operationOutcome: "unknown",
|
||||
detail: fallbackMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRawBackendError(error: unknown): BackendError | null {
|
||||
if (typeof error === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(error);
|
||||
return normalizeBackendError(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return normalizeBackendError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an unknown error value into a human-readable string.
|
||||
* Handles Error objects, strings, null/undefined, and other types.
|
||||
@@ -17,6 +98,9 @@
|
||||
* }
|
||||
*/
|
||||
export function formatError(e: unknown): string {
|
||||
const backendError = normalizeBackendError(e);
|
||||
if (backendError?.detail) return backendError.detail;
|
||||
|
||||
if (e instanceof Error) {
|
||||
return e.message;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
TunnelProfile,
|
||||
} from "@/types/database";
|
||||
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
|
||||
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
@@ -223,19 +224,19 @@ async function post<T>(url: string, body: unknown): Promise<T> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function get<T>(url: string): Promise<T> {
|
||||
const res = await fetch(apiUrl(url));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function del<T>(url: string): Promise<T> {
|
||||
const res = await fetch(apiUrl(url), { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -245,10 +246,21 @@ async function put<T>(url: string, body: unknown): Promise<T> {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
if (!res.ok) throw await backendResponseError(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function backendResponseError(response: Response): Promise<BackendErrorException> {
|
||||
const text = await response.text();
|
||||
let payload: unknown = text;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
// Preserve legacy plain-text responses at the same compatibility boundary.
|
||||
}
|
||||
return new BackendErrorException(payload);
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | boolean | undefined>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
@@ -880,7 +892,7 @@ export interface ExecuteMultiProgress {
|
||||
success: boolean;
|
||||
executionTimeMs: number;
|
||||
affectedRows: number;
|
||||
error?: string;
|
||||
error?: BackendError;
|
||||
}
|
||||
|
||||
export async function executeMultiWithProgress(
|
||||
@@ -918,7 +930,7 @@ export async function executeMultiWithProgress(
|
||||
success,
|
||||
executionTimeMs: result.execution_time_ms,
|
||||
affectedRows: result.affected_rows,
|
||||
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
|
||||
error: success ? undefined : result.error,
|
||||
});
|
||||
});
|
||||
return results;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { BackendErrorException, type BackendError } from "@/lib/backend/errorUtils";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { normalizeRustMongoCommand, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
|
||||
import { ExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
|
||||
@@ -1060,14 +1061,18 @@ export async function executeQuery(
|
||||
executionMode?: "simple";
|
||||
},
|
||||
): Promise<QueryResult> {
|
||||
return invoke("execute_query", {
|
||||
connectionId,
|
||||
database,
|
||||
sql,
|
||||
schema,
|
||||
executionId,
|
||||
...options,
|
||||
});
|
||||
try {
|
||||
return await invoke("execute_query", {
|
||||
connectionId,
|
||||
database,
|
||||
sql,
|
||||
schema,
|
||||
executionId,
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BackendErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeMulti(
|
||||
@@ -1089,14 +1094,18 @@ export async function executeMulti(
|
||||
executionMode?: "simple";
|
||||
},
|
||||
): Promise<QueryResult[]> {
|
||||
return invoke("execute_multi", {
|
||||
connectionId,
|
||||
database,
|
||||
sql,
|
||||
schema,
|
||||
executionId,
|
||||
...options,
|
||||
});
|
||||
try {
|
||||
return await invoke("execute_multi", {
|
||||
connectionId,
|
||||
database,
|
||||
sql,
|
||||
schema,
|
||||
executionId,
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BackendErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecuteMultiProgress {
|
||||
@@ -1107,7 +1116,7 @@ export interface ExecuteMultiProgress {
|
||||
success: boolean;
|
||||
executionTimeMs: number;
|
||||
affectedRows: number;
|
||||
error?: string;
|
||||
error?: BackendError;
|
||||
}
|
||||
|
||||
export async function executeMultiWithProgress(
|
||||
@@ -1137,6 +1146,8 @@ export async function executeMultiWithProgress(
|
||||
});
|
||||
try {
|
||||
return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...invokeOptions });
|
||||
} catch (error) {
|
||||
throw new BackendErrorException(error);
|
||||
} finally {
|
||||
unlisten();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
|
||||
export interface ExplainPlanNode {
|
||||
id: string;
|
||||
@@ -376,7 +377,7 @@ export function flattenExplainPlanNodes(nodes: ExplainPlanNode[]): ExplainPlanNo
|
||||
}
|
||||
|
||||
export function sqlServerExplainResult(results: QueryResult[]): { result?: QueryResult; error?: string } {
|
||||
const errorResult = results.find((result) => result.columns.length === 1 && result.columns[0] === "Error" && result.rows.length > 0);
|
||||
const errorResult = results.find((result) => isQueryExecutionErrorResult(result) && result.rows.length > 0);
|
||||
if (errorResult) return { error: String(errorResult.rows[0]?.[0] ?? "") };
|
||||
|
||||
const result = results.find((candidate) => firstSqlServerShowplanXml(candidate) !== undefined);
|
||||
|
||||
@@ -25,6 +25,6 @@ export function isQueryExecutionErrorResult(result: QueryResult): boolean {
|
||||
}
|
||||
|
||||
export function isNoSnapshotErrorResult(result: QueryResult | undefined | null): boolean {
|
||||
if (!result || !result.columns.includes("Error") || result.rows.length === 0) return false;
|
||||
if (!result || !isQueryExecutionErrorResult(result) || result.rows.length === 0) return false;
|
||||
return NO_SNAPSHOT_ERROR_PATTERN.test(String(result.rows[0]?.[0] ?? ""));
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { QueryResult, QueryTab } from "@/types/database";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
|
||||
export type DataTableDoubleClickAction = "activate" | "open" | "none";
|
||||
|
||||
function isErrorResult(result: QueryResult | undefined): boolean {
|
||||
return result?.columns.length === 1 && result.columns[0] === "Error";
|
||||
}
|
||||
|
||||
export function canActivateExistingDataTableTab(tab: QueryTab, options: { activateExecuting?: boolean } = {}): boolean {
|
||||
if (tab.isExecuting) return options.activateExecuting !== false;
|
||||
if (isErrorResult(tab.result)) return false;
|
||||
if (tab.result && isQueryExecutionErrorResult(tab.result)) return false;
|
||||
return !!tab.result || !!tab.results?.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
|
||||
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
|
||||
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
|
||||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -378,7 +379,7 @@ export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" |
|
||||
});
|
||||
}
|
||||
return results.map((result, index) => {
|
||||
const isError = result.execution_error === true || result.columns.includes("Error");
|
||||
const isError = isQueryExecutionErrorResult(result);
|
||||
return {
|
||||
result,
|
||||
index,
|
||||
|
||||
Reference in new issue
Block a user