fix(mysql): stop multi-statement execution after first error

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-12 21:30:27 +08:00
1 parent 6368c49c16
commit 7a42b1e96b
12 files changed
+523 -47

No files matched your search

@@ -2,6 +2,7 @@ import { computed, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { requiresDatabaseSelection, useSqlExecution } from "../useSqlExecution";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -140,6 +141,88 @@ describe("useSqlExecution", () => {
expect(executedSql).toContain("where fp.create_at < @date_start");
});
it("records a later MySQL batch error and skips metadata refresh", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
const connectionStore = useConnectionStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
const tab = activeTab.value;
if (!tab) return;
const successfulResult = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
tab.result = successfulResult;
tab.results = [successfulResult, { columns: ["Error"], execution_error: true, rows: [["Duplicate entry '1'"]], affected_rows: 0, execution_time_ms: 1 }];
tab.activeResultIndex = 0;
});
const addHistory = vi.spyOn(historyStore, "add").mockResolvedValue(undefined);
const refreshObjects = vi.spyOn(connectionStore, "refreshObjectListTreeNode").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "SELECT 1 AS value; CREATE TABLE duplicate_target (id INT)"),
activeOutputView,
});
await execution.tryExecute();
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "Duplicate entry '1'", affected_rows: undefined }));
expect(refreshObjects).not.toHaveBeenCalled();
});
it("does not treat an unmarked MySQL Error alias as a batch failure", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
const tab = activeTab.value;
if (!tab) return;
const successfulResult = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
tab.result = successfulResult;
tab.results = [successfulResult, { columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }];
tab.activeResultIndex = 0;
});
const addHistory = vi.spyOn(historyStore, "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "SELECT 1 AS value; SELECT 2 AS Error"),
activeOutputView,
});
await execution.tryExecute();
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: true, error: undefined }));
});
it("continues to record active non-MySQL errors as failures", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("app"));
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["Error"], rows: [["relation does not exist"]], affected_rows: 0, execution_time_ms: 1 };
});
const addHistory = vi.spyOn(historyStore, "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "SELECT * FROM missing_table"),
activeOutputView,
});
await execution.tryExecute();
expect(addHistory).toHaveBeenCalledWith(expect.objectContaining({ success: false, error: "relation does not exist" }));
});
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("prod_app"));
const activeConnection = ref<ConnectionConfig | undefined>({ ...connection("mysql"), production_databases: ["prod_app"] });
@@ -9,6 +9,7 @@ import { isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCap
import { canExecuteWithoutSelectedDatabase } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
import { isMysqlExecutionErrorResult, usesMysqlProtocolDatabaseType } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety, firstRedisCommandToken } from "@/lib/redis/redisCommandSafety";
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters";
@@ -41,6 +42,16 @@ function primarySqlOperation(sql: string): string {
return statement?.match(/^([a-z]+)/i)?.[1]?.toUpperCase() || "SQL";
}
function firstQueryExecutionError(tab: Pick<QueryTab, "result" | "results">, databaseType: DatabaseType | undefined) {
const activeResult = tab.result;
if (activeResult && isMysqlExecutionErrorResult(activeResult, databaseType)) return activeResult;
if (!usesMysqlProtocolDatabaseType(databaseType) && activeResult?.columns.includes("Error")) return activeResult;
if (!usesMysqlProtocolDatabaseType(databaseType)) return undefined;
const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : [];
return results.find((result) => isMysqlExecutionErrorResult(result, databaseType));
}
export function useSqlExecution(deps: {
activeTab: ComputedRef<QueryTab | undefined>;
activeConnection: ComputedRef<ConnectionConfig | undefined>;
@@ -146,20 +157,23 @@ export function useSqlExecution(deps: {
sql ??= await resolvedExecutableSql();
const tab = deps.activeTab.value;
if (!tab || !sql.trim()) return;
if (requiresDatabaseSelection(tab, deps.activeConnection.value, sql)) {
const executionConnection = connectionStore.getConfig(tab.connectionId) ?? deps.activeConnection.value;
const executionDatabaseType = executionConnection?.db_type;
if (requiresDatabaseSelection(tab, executionConnection, sql)) {
deps.onMissingDatabase?.();
return;
}
deps.activeOutputView.value = "result";
const connName = connectionStore.getConfig(tab.connectionId)?.name || "";
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = deps.activeConnection.value?.db_type === "redis";
const isRedis = executionDatabaseType === "redis";
await queryStore.executeCurrentSql(sql, isRedis ? { skipRedisSafetyCheck: deps.blockDangerousRedisCommands?.value === false } : undefined);
if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
deps.activeOutputView.value = "summary";
}
const elapsed = Date.now() - start;
const success = !tab.result?.columns.includes("Error");
const failure = firstQueryExecutionError(tab, executionDatabaseType);
const success = !failure;
historyStore.add({
connection_id: tab.connectionId,
connection_name: connName,
@@ -167,7 +181,7 @@ export function useSqlExecution(deps: {
sql,
execution_time_ms: elapsed,
success,
error: success ? undefined : String(tab.result?.rows?.[0]?.[0] ?? ""),
error: failure ? String(failure.rows?.[0]?.[0] ?? "") : undefined,
activity_kind: classifySqlActivityKind(sql),
operation: primarySqlOperation(sql),
affected_rows: success ? tab.result?.affected_rows : undefined,
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { QueryResult } from "@/types/database";
import { isNoSnapshotErrorResult } from "@/lib/query/queryResultError";
import { isMysqlExecutionErrorResult, isNoSnapshotErrorResult } from "@/lib/query/queryResultError";
function errorResult(message: string): QueryResult {
return { columns: ["Error"], rows: [[message]], affected_rows: 0, execution_time_ms: 0 };
@@ -39,3 +39,23 @@ describe("isNoSnapshotErrorResult", () => {
expect(isNoSnapshotErrorResult({ ...errorResult("There is currently no snapshot."), rows: [] })).toBe(false);
});
});
describe("isMysqlExecutionErrorResult", () => {
it("recognizes an explicitly marked MySQL batch execution error", () => {
expect(isMysqlExecutionErrorResult({ ...errorResult("Duplicate entry '1'"), execution_error: true }, "mysql")).toBe(true);
});
it("does not mistake an unmarked Error alias without type metadata for an execution error", () => {
const result: QueryResult = {
columns: ["Error"],
rows: [["2"]],
affected_rows: 0,
execution_time_ms: 1,
};
expect(isMysqlExecutionErrorResult(result, "mysql")).toBe(false);
});
it("does not apply the native MySQL heuristic to JDBC connections", () => {
expect(isMysqlExecutionErrorResult({ ...errorResult("Duplicate entry '1'"), execution_error: true }, "jdbc")).toBe(false);
});
});
+12 -1
View File
@@ -1,4 +1,4 @@
import type { QueryResult } from "@/types/database";
import type { DatabaseType, QueryResult } from "@/types/database";
// Lake/external tables (e.g. Paimon in StarRocks) return this error on a data
// read when no snapshot exists yet, while metadata reads (DESC/SHOW CREATE)
@@ -6,6 +6,17 @@ import type { QueryResult } from "@/types/database";
// callers can detect this case and fall back to a structure-only (LIMIT 0)
// preview instead of showing a cryptic server error.
const NO_SNAPSHOT_ERROR_PATTERN = /there is currently no snapshot/i;
const MYSQL_PROTOCOL_DATABASE_TYPES = new Set<DatabaseType>(["mysql", "doris", "starrocks", "manticoresearch"]);
export function usesMysqlProtocolDatabaseType(databaseType: DatabaseType | undefined): boolean {
return databaseType !== undefined && MYSQL_PROTOCOL_DATABASE_TYPES.has(databaseType);
}
// The batch executor marks synthesized MySQL-protocol errors explicitly so a
// successful result column named Error is never mistaken for a failure.
export function isMysqlExecutionErrorResult(result: QueryResult, databaseType: DatabaseType | undefined): boolean {
return usesMysqlProtocolDatabaseType(databaseType) && result.execution_error === true;
}
export function isNoSnapshotErrorResult(result: QueryResult | undefined | null): boolean {
if (!result || !result.columns.includes("Error") || result.rows.length === 0) return false;
@@ -40,6 +40,7 @@ export interface TabResultSnapshot {
interface ColumnarQueryResult {
columns: string[];
execution_error?: true;
column_types?: string[];
columnValues: CellValue[][];
rowCount: number;
@@ -134,6 +135,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
if (!result) return undefined;
return {
columns: [...result.columns],
execution_error: result.execution_error,
column_types: result.column_types ? [...result.column_types] : undefined,
rows: result.rows.map((row) => [...row]),
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
@@ -167,6 +169,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
const columnValues = result.columns.map((_, colIndex) => result.rows.map((row) => row[colIndex] ?? null));
return removeUndefinedFields({
columns: [...result.columns],
execution_error: result.execution_error,
column_types: result.column_types ? [...result.column_types] : undefined,
columnValues,
rowCount: result.rows.length,
@@ -185,6 +188,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
const rows = Array.from({ length: result.rowCount }, (_, rowIndex) => result.columnValues.map((values) => values[rowIndex] ?? null));
return {
columns: [...result.columns],
execution_error: result.execution_error,
column_types: result.column_types ? [...result.column_types] : undefined,
rows,
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
@@ -0,0 +1,125 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
analyzeEditableQueryEditability: vi.fn(),
closeClientConnectionSession: vi.fn(),
closeQuerySession: vi.fn(),
executeMulti: vi.fn(),
getConnectionConfig: vi.fn(),
prepareQueryPaginationExecutionPlan: vi.fn(),
saveOpenTabsState: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability,
closeClientConnectionSession: mocks.closeClientConnectionSession,
closeQuerySession: mocks.closeQuerySession,
executeMulti: mocks.executeMulti,
prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
ensureConnected: vi.fn().mockResolvedValue(undefined),
getConfig: mocks.getConnectionConfig,
recordConnectionLostError: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: { autoCalculateTotalRows: false, pageSize: 100 },
}),
}));
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 multi-statement errors", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConnectionConfig.mockReturnValue({
id: "mysql-1",
name: "MySQL",
db_type: "mysql",
database: "app",
query_timeout_secs: 30,
});
mocks.prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "multiple-statements" });
});
it("opens the first error result from a mixed result batch", async () => {
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], execution_error: true, rows: [["no such table: missing"]], affected_rows: 0, execution_time_ms: 1 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, "SELECT 1 AS value; SELECT * FROM missing");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.activeResultIndex).toBe(1);
expect(tab.result?.columns).toEqual(["Error"]);
});
it("does not promote an unmarked Error alias without type metadata as a batch failure", async () => {
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, "SELECT 1 AS value; SELECT 2 AS Error");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.activeResultIndex).toBe(0);
expect(tab.result?.columns).toEqual(["value"]);
});
it("does not apply the MySQL result heuristic to a JDBC MySQL dialect", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "mysql-1",
name: "JDBC MySQL",
db_type: "jdbc",
connection_string: "jdbc:mysql://localhost:3306/app",
database: "app",
query_timeout_secs: 30,
});
mocks.executeMulti.mockResolvedValue([
{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 },
{ columns: ["Error"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 },
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query");
await store.executeTabSql(tabId, "SELECT 1 AS value; SELECT 2 AS Error");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.activeResultIndex).toBe(0);
expect(tab.result?.columns).toEqual(["value"]);
});
});
+4 -1
View File
@@ -45,6 +45,7 @@ import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sq
import { clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { buildTabResultSnapshot, deleteTabResultSnapshot, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
import { queryResultBaseSql, queryResultExecutionSql } from "@/lib/tabs/tabPresentation";
import { isMysqlExecutionErrorResult } from "@/lib/query/queryResultError";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -1982,6 +1983,7 @@ export const useQueryStore = defineStore("query", () => {
const message = e instanceof Error ? e.message : String(e);
return markQueryResultRowsRaw({
columns: ["Error"],
execution_error: true,
rows: [[message]],
affected_rows: 0,
execution_time_ms: 0,
@@ -2920,8 +2922,9 @@ export const useQueryStore = defineStore("query", () => {
current.results[activeGroupIndex] = results[0];
current.result = results[0];
} else if (results.length > 1) {
const errorResultIndex = results.findIndex((result) => isMysqlExecutionErrorResult(result, conn?.db_type));
const activeResultIndex = results.findIndex((result) => result.columns.length > 0);
const resultIndex = preservedResultIndex(results, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0);
const resultIndex = errorResultIndex >= 0 ? errorResultIndex : (preservedResultIndex(results, current.activeResultIndex, options?.preserveActiveResultIndex) ?? (activeResultIndex >= 0 ? activeResultIndex : 0));
current.results = results;
current.activeResultIndex = resultIndex;
current.result = results[resultIndex];
+2
View File
@@ -449,6 +449,8 @@ export interface OwnerInfo {
export interface QueryResult {
columns: string[];
/** Set for synthesized query execution failures. */
execution_error?: true;
/** Internal row identifiers appended to editable query results. */
hidden_column_indexes?: number[];
/**