diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue
index 32358b2938..f4bf2ec526 100644
--- a/apps/desktop/src/components/layout/ContentArea.vue
+++ b/apps/desktop/src/components/layout/ContentArea.vue
@@ -5,6 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
+import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import {
@@ -36,6 +37,11 @@ import {
AlignRight,
PanelsTopLeft,
Palette,
+ CircleAlert,
+ CircleStop,
+ RotateCcw,
+ SkipForward,
+ ListX,
} from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
@@ -480,6 +486,7 @@ watch(
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
const batchExecutionProgress = computed(() => props.activeTab.batchSqlExecution);
+const batchRecovery = computed(() => batchSqlRecoveryState(props.activeTab));
const batchExecutionPercent = computed(() => {
const progress = batchExecutionProgress.value;
return progress?.total ? Math.round((progress.completed / progress.total) * 100) : 0;
@@ -978,6 +985,14 @@ async function copyExecutionSummaryError(error: string) {
}
}
+function dismissBatchRecovery() {
+ queryStore.dismissBatchSqlRecovery(props.activeTab.id);
+}
+
+function resumeBatchExecution(action: BatchSqlRecoveryAction) {
+ void queryStore.resumeBatchSql(props.activeTab.id, action);
+}
+
function handleModRTarget(target: Element): boolean {
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
@@ -1511,6 +1526,32 @@ defineExpose({
{{ t("executionSummary.statement") }}
{{ t("executionSummary.sql") }}
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 7c12387526..f897a5e161 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -1481,6 +1481,12 @@ export default {
noTable: "No result table returned",
noSql: "SQL unavailable",
navigationHint: "Click a row to preview its SQL; double-click to focus it in the editor.",
+ recoveryPrompt: "Statement #{statement} failed; {count} statements were not run.",
+ stop: "Stop",
+ retry: "Retry",
+ skipAndContinue: "Skip and continue",
+ skipAll: "Skip all errors",
+ skipAllHint: "Continue and automatically skip later errors in this batch",
statuses: {
pending: "Pending",
running: "Running",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index fa77333412..08bbef154a 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -1422,6 +1422,12 @@ export default withEnglishFallback({
noTable: "No se devolvió ninguna tabla de resultados",
noSql: "SQL no disponible",
navigationHint: "Haz clic para previsualizar el SQL; haz doble clic para enfocarlo en el editor.",
+ recoveryPrompt: "Falló la sentencia #{statement}; no se ejecutaron {count} sentencias.",
+ stop: "Detener",
+ retry: "Reintentar",
+ skipAndContinue: "Omitir y continuar",
+ skipAll: "Omitir todos los errores",
+ skipAllHint: "Continuar y omitir automáticamente los errores posteriores de este lote",
statuses: {
pending: "Pendiente",
running: "Ejecutando",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 69677f48f4..b15f8604c5 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -1420,6 +1420,12 @@ export default withEnglishFallback({
noTable: "Nessuna tabella restituita",
noSql: "SQL non disponibile",
navigationHint: "Fai clic per visualizzare l'SQL; fai doppio clic per selezionarlo nell'editor.",
+ recoveryPrompt: "L'istruzione #{statement} non è riuscita; {count} istruzioni non sono state eseguite.",
+ stop: "Interrompi",
+ retry: "Riprova",
+ skipAndContinue: "Ignora e continua",
+ skipAll: "Ignora tutti gli errori",
+ skipAllHint: "Continua e ignora automaticamente gli errori successivi di questo batch",
statuses: {
pending: "In attesa",
running: "In esecuzione",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 06be0fdffd..dd9436bd16 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -1442,6 +1442,12 @@ export default withEnglishFallback({
noTable: "結果テーブルは返されませんでした",
noSql: "SQLを取得できません",
navigationHint: "クリックでSQLをプレビューし、ダブルクリックでエディター内を選択します。",
+ recoveryPrompt: "ステートメント #{statement} が失敗し、残り {count} 件は未実行です。",
+ stop: "停止",
+ retry: "再試行",
+ skipAndContinue: "スキップして続行",
+ skipAll: "すべてのエラーをスキップ",
+ skipAllHint: "続行し、このバッチで後続のエラーを自動的にスキップします",
statuses: {
pending: "待機中",
running: "実行中",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 67ba423bda..19c4be5af4 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -1362,6 +1362,12 @@ export default withEnglishFallback({
noTable: "반환된 결과 테이블이 없습니다",
noSql: "SQL을 가져올 수 없습니다",
navigationHint: "행을 클릭하여 SQL을 미리 보고 두 번 클릭하여 편집기에서 선택하세요.",
+ recoveryPrompt: "구문 #{statement} 실행에 실패하여 나머지 {count}개 구문이 실행되지 않았습니다.",
+ stop: "중지",
+ retry: "다시 시도",
+ skipAndContinue: "건너뛰고 계속",
+ skipAll: "모든 오류 건너뛰기",
+ skipAllHint: "계속 실행하고 이 배치의 이후 오류를 자동으로 건너뜁니다",
statuses: {
pending: "대기 중",
running: "실행 중",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 1716d3da35..0f1c39f0d4 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -1422,6 +1422,12 @@ export default withEnglishFallback({
noTable: "Nenhuma tabela de resultado retornada",
noSql: "SQL indisponível",
navigationHint: "Clique para visualizar o SQL; clique duas vezes para selecioná-lo no editor.",
+ recoveryPrompt: "A instrução #{statement} falhou; {count} instruções não foram executadas.",
+ stop: "Parar",
+ retry: "Tentar novamente",
+ skipAndContinue: "Ignorar e continuar",
+ skipAll: "Ignorar todos os erros",
+ skipAllHint: "Continuar e ignorar automaticamente os erros posteriores deste lote",
statuses: {
pending: "Pendente",
running: "Executando",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 0f95a3ba8e..33c96e7b2f 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -1405,6 +1405,12 @@ export default withEnglishFallback({
noTable: "未返回结果表",
noSql: "无法获取 SQL",
navigationHint: "单击预览对应 SQL,双击在编辑器中聚焦并选中。",
+ recoveryPrompt: "语句 #{statement} 执行失败,剩余 {count} 条未执行。",
+ stop: "停止",
+ retry: "重试",
+ skipAndContinue: "跳过并继续",
+ skipAll: "全部忽略",
+ skipAllHint: "继续执行,并自动跳过当前批次后续错误",
statuses: {
pending: "等待",
running: "执行中",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index a932bf1c67..7222579e11 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -1421,6 +1421,12 @@ export default withEnglishFallback({
noTable: "未回傳結果表",
noSql: "無法取得 SQL",
navigationHint: "按一下預覽 SQL,按兩下在編輯器中聚焦並選取。",
+ recoveryPrompt: "語句 #{statement} 執行失敗,剩餘 {count} 條未執行。",
+ stop: "停止",
+ retry: "重試",
+ skipAndContinue: "略過並繼續",
+ skipAll: "全部忽略",
+ skipAllHint: "繼續執行,並自動略過目前批次後續錯誤",
statuses: {
pending: "等待",
running: "執行中",
diff --git a/apps/desktop/src/lib/__tests__/query/batchSqlRecovery.spec.ts b/apps/desktop/src/lib/__tests__/query/batchSqlRecovery.spec.ts
new file mode 100644
index 0000000000..360800f948
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/query/batchSqlRecovery.spec.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, prepareBatchSqlRecovery } from "@/lib/query/batchSqlRecovery";
+import type { BatchSqlExecution, QueryResult } from "@/types/database";
+
+function batch(): BatchSqlExecution {
+ return {
+ executionId: "first",
+ submittedSql: "SELECT 1;\nSELECT bad;\nSELECT 3",
+ editorFingerprint: "editor",
+ sourceOffset: 10,
+ completed: 2,
+ total: 3,
+ startedAt: 1,
+ finishedAt: 2,
+ items: [
+ { statementIndex: 0, sql: "SELECT 1", from: 10, to: 18, status: "success" },
+ {
+ statementIndex: 1,
+ sql: "SELECT bad",
+ from: 20,
+ to: 30,
+ status: "error",
+ error: "bad SQL",
+ errorDetails: {
+ version: 1,
+ code: "DBX-JDBC-4001",
+ messageKey: "backendErrors.jdbc.sqlFailed",
+ messageParams: { stage: "execute" },
+ source: "jdbcAgent",
+ operationOutcome: "unknown",
+ diagnostics: { category: "sql" },
+ },
+ },
+ { statementIndex: 2, sql: "SELECT 3", from: 32, to: 40, status: "skipped" },
+ ],
+ };
+}
+
+describe("batchSqlRecovery", () => {
+ it("offers recovery only for a stopped auto-commit batch with a safe SQL error", () => {
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: batch() })).toEqual({ failedStatementIndex: 1, remainingStatementCount: 1 });
+ expect(batchSqlRecoveryState({ autoCommit: false, isExecuting: false, batchSqlExecution: batch() })).toBeUndefined();
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: true, batchSqlExecution: batch() })).toBeUndefined();
+
+ const unsafe = batch();
+ unsafe.items[1]!.errorDetails = { ...unsafe.items[1]!.errorDetails!, code: "DBX-JDBC-2002", diagnostics: { category: "timeout" } };
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: unsafe })).toBeUndefined();
+
+ const legacy = batch();
+ legacy.items[1]!.errorDetails = {
+ ...legacy.items[1]!.errorDetails!,
+ code: "DBX-LEGACY-0001",
+ messageKey: "backendErrors.legacy",
+ source: "legacyBackend",
+ diagnostics: undefined,
+ };
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: legacy })).toBeUndefined();
+
+ legacy.items[2]!.status = "pending";
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: legacy })).toBeUndefined();
+
+ legacy.items[1]!.errorDetails = undefined;
+ legacy.items[1]!.executionTimeMs = 0;
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: legacy })).toBeUndefined();
+
+ const dismissed = batch();
+ dismissed.recoveryDismissed = true;
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: dismissed })).toBeUndefined();
+ });
+
+ it("uses the latest failed statement when a resumed batch stops again", () => {
+ const value = batch();
+ value.items.push({ statementIndex: 3, sql: "SELECT bad2", from: 42, to: 53, status: "error", errorDetails: value.items[1]!.errorDetails }, { statementIndex: 4, sql: "SELECT 5", from: 55, to: 63, status: "skipped" });
+ value.total = 5;
+ expect(batchSqlRecoveryState({ autoCommit: true, isExecuting: false, batchSqlExecution: value })).toEqual({ failedStatementIndex: 3, remainingStatementCount: 1 });
+ });
+
+ it("restarts from the original submitted SQL and resets only the resumed tail", () => {
+ expect(batchSqlRecoverySql(batch(), 1)).toEqual({ sql: "SELECT bad;\nSELECT 3", sourceOffset: 20 });
+ expect(prepareBatchSqlRecovery(batch(), "second", 2)).toMatchObject({
+ executionId: "second",
+ completed: 2,
+ finishedAt: undefined,
+ items: [{ status: "success" }, { status: "error" }, { status: "running", error: undefined }],
+ });
+ });
+
+ it("replaces results by global statement index while retaining earlier results", () => {
+ const previous = [
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: 0 },
+ { columns: ["message"], rows: [["second result set"]], affected_rows: 0, execution_time_ms: 1, statement_index: 0 },
+ { columns: ["Error"], rows: [["bad"]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, execution_error: true },
+ ] as QueryResult[];
+ const resumed = [
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: 1 },
+ { columns: ["message"], rows: [["retried result set"]], affected_rows: 0, execution_time_ms: 1, statement_index: 1 },
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: 2 },
+ ] as QueryResult[];
+
+ expect(mergeBatchQueryResults(previous, resumed)).toEqual([previous[0], previous[1], resumed[0], resumed[1], resumed[2]]);
+ });
+});
diff --git a/apps/desktop/src/lib/query/batchSqlRecovery.ts b/apps/desktop/src/lib/query/batchSqlRecovery.ts
new file mode 100644
index 0000000000..ec764cb428
--- /dev/null
+++ b/apps/desktop/src/lib/query/batchSqlRecovery.ts
@@ -0,0 +1,69 @@
+import type { BatchSqlExecution, QueryResult, QueryTab } from "@/types/database";
+
+export type BatchSqlRecoveryAction = "retry" | "skip" | "skip-all";
+
+export interface BatchSqlRecoveryState {
+ failedStatementIndex: number;
+ remainingStatementCount: number;
+}
+
+function isRecoverableStatementError(batch: BatchSqlExecution, statementIndex: number): boolean {
+ const error = batch.items[statementIndex]?.errorDetails;
+ return error?.code === "DBX-JDBC-4001" || error?.diagnostics?.category === "sql";
+}
+
+export function batchSqlRecoveryState(tab: Pick
): BatchSqlRecoveryState | undefined {
+ const batch = tab.batchSqlExecution;
+ if (!batch || tab.isExecuting || tab.autoCommit === false || batch.recoveryDismissed === true) return undefined;
+
+ for (let failedStatementIndex = batch.items.length - 1; failedStatementIndex >= 0; failedStatementIndex -= 1) {
+ if (batch.items[failedStatementIndex]?.status !== "error" || !isRecoverableStatementError(batch, failedStatementIndex)) continue;
+ const remainingStatementCount = batch.items.slice(failedStatementIndex + 1).filter((item) => item.status === "skipped" || item.status === "pending").length;
+ if (remainingStatementCount > 0) return { failedStatementIndex, remainingStatementCount };
+ }
+ return undefined;
+}
+
+export function prepareBatchSqlRecovery(batch: BatchSqlExecution, executionId: string, startStatementIndex: number): BatchSqlExecution {
+ const resumed: BatchSqlExecution = {
+ ...batch,
+ executionId,
+ finishedAt: undefined,
+ recoveryDismissed: false,
+ items: batch.items.map((item) => ({ ...item })),
+ };
+
+ for (let index = startStatementIndex; index < resumed.items.length; index += 1) {
+ const item = resumed.items[index]!;
+ item.status = index === startStatementIndex ? "running" : "pending";
+ item.executionTimeMs = undefined;
+ item.affectedRows = undefined;
+ item.error = undefined;
+ item.errorDetails = undefined;
+ }
+ resumed.completed = resumed.items.filter((item) => item.status === "success" || item.status === "error").length;
+ return resumed;
+}
+
+export function batchSqlRecoverySql(batch: BatchSqlExecution, startStatementIndex: number): { sql: string; sourceOffset: number } | undefined {
+ const item = batch.items[startStatementIndex];
+ if (!item) return undefined;
+ const relativeFrom = item.from - batch.sourceOffset;
+ if (relativeFrom < 0 || relativeFrom >= batch.submittedSql.length) return undefined;
+ return { sql: batch.submittedSql.slice(relativeFrom), sourceOffset: item.from };
+}
+
+export function offsetBatchQueryResultIndexes(results: QueryResult[], statementOffset: number): QueryResult[] {
+ if (statementOffset === 0) return results;
+ for (const [fallbackIndex, result] of results.entries()) {
+ result.statement_index = statementOffset + (Number.isInteger(result.statement_index) ? result.statement_index! : fallbackIndex);
+ }
+ return results;
+}
+
+export function mergeBatchQueryResults(previous: QueryResult[], resumed: QueryResult[]): QueryResult[] {
+ if (resumed.length === 0) return previous;
+ const resumedStart = Math.min(...resumed.map((result, fallbackIndex) => (Number.isInteger(result.statement_index) ? result.statement_index! : fallbackIndex)));
+ const retained = previous.filter((result, fallbackIndex) => (Number.isInteger(result.statement_index) ? result.statement_index! : fallbackIndex) < resumedStart);
+ return [...retained, ...resumed];
+}
diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts
index b116cdf453..c03568bc99 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts
@@ -94,6 +94,19 @@ function structuredTimeoutError() {
};
}
+function structuredSqlError(detail = "duplicate key") {
+ return {
+ version: 1 as const,
+ code: "DBX-JDBC-4001",
+ messageKey: "backendErrors.jdbc.sqlFailed",
+ messageParams: { stage: "execute" },
+ source: "jdbcAgent" as const,
+ operationOutcome: "unknown" as const,
+ detail,
+ diagnostics: { category: "sql", stage: "execute" },
+ };
+}
+
describe("queryStore multi-statement errors", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -204,6 +217,141 @@ describe("queryStore multi-statement errors", () => {
});
});
+ it("skips a failed statement and continues the original batch without replaying successful statements", async () => {
+ const sqlError = structuredSqlError();
+ const sql = "INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3)";
+ mocks.executeMultiWithProgress
+ .mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 0 },
+ { columns: ["Error"], rows: [["duplicate key"]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, execution_error: true, error: sqlError },
+ ])
+ .mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 0 },
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 1 },
+ ]);
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
+
+ await store.executeTabSql(tabId, sql);
+ expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items).toMatchObject([{ status: "success" }, { status: "error" }, { status: "skipped" }, { status: "skipped" }]);
+
+ await expect(store.resumeBatchSql(tabId, "skip")).resolves.toBe(true);
+
+ expect(mocks.executeMultiWithProgress).toHaveBeenCalledTimes(2);
+ expect(mocks.executeMultiWithProgress.mock.calls[1]?.[2]).toBe("INSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3)");
+ expect(mocks.executeMultiWithProgress.mock.calls[1]?.[5]).toMatchObject({ continueOnError: false });
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ expect(tab.batchSqlExecution?.items).toMatchObject([{ status: "success" }, { status: "error" }, { status: "success" }, { status: "success" }]);
+ expect(tab.results?.map((result) => result.statement_index)).toEqual([0, 1, 2, 3]);
+ expect(tab.results?.[1]?.execution_error).toBe(true);
+ });
+
+ it("retries the failed statement and replaces its previous error result", async () => {
+ const sqlError = structuredSqlError();
+ const sql = "INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3)";
+ mocks.executeMultiWithProgress
+ .mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 0 },
+ { columns: ["Error"], rows: [["duplicate key"]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, execution_error: true, error: sqlError },
+ ])
+ .mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 0 },
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 1 },
+ ]);
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
+
+ await store.executeTabSql(tabId, sql);
+ await expect(store.resumeBatchSql(tabId, "retry")).resolves.toBe(true);
+
+ expect(mocks.executeMultiWithProgress.mock.calls[1]?.[2]).toBe("INSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3)");
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ expect(tab.batchSqlExecution?.items).toMatchObject([{ status: "success" }, { status: "success" }, { status: "success" }]);
+ expect(tab.results?.map((result) => ({ index: result.statement_index, error: result.execution_error }))).toEqual([
+ { index: 0, error: undefined },
+ { index: 1, error: undefined },
+ { index: 2, error: undefined },
+ ]);
+ });
+
+ it("retries against the original execution target after the tab target changes", async () => {
+ const sqlError = structuredSqlError();
+ const sql = "INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2)";
+ mocks.executeMultiWithProgress.mockResolvedValueOnce([{ columns: ["Error"], rows: [["duplicate key"]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, execution_error: true, error: sqlError }]).mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 0 },
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 1 },
+ ]);
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
+
+ await store.executeTabSql(tabId, sql, {
+ executionTarget: {
+ connectionId: "mysql-original",
+ catalog: "catalog-original",
+ database: "database-original",
+ schema: "schema-original",
+ },
+ });
+ const tab = store.tabs.find((item) => item.id === tabId)!;
+ tab.connectionId = "mysql-current";
+ tab.catalog = "catalog-current";
+ tab.database = "database-current";
+ tab.schema = "schema-current";
+
+ await expect(store.resumeBatchSql(tabId, "retry")).resolves.toBe(true);
+
+ expect(mocks.executeMultiWithProgress.mock.calls[1]?.slice(0, 5)).toEqual(["mysql-original", "database-original", sql, expect.any(Function), "schema-original"]);
+ expect(mocks.executeMultiWithProgress.mock.calls[1]?.[5]).toMatchObject({ catalog: "catalog-original" });
+ });
+
+ it("continues past all later SQL errors only for the resumed batch", async () => {
+ const sqlError = structuredSqlError();
+ const sql = "INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3)";
+ mocks.executeMultiWithProgress.mockResolvedValueOnce([{ columns: ["Error"], rows: [["duplicate key"]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, execution_error: true, error: sqlError }]).mockResolvedValueOnce([
+ { columns: ["Error"], rows: [["another duplicate"]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, execution_error: true, error: sqlError },
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 2, statement_index: 1 },
+ ]);
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
+
+ await store.executeTabSql(tabId, sql);
+ await expect(store.resumeBatchSql(tabId, "skip-all")).resolves.toBe(true);
+
+ expect(mocks.executeMultiWithProgress.mock.calls[1]?.[5]).toMatchObject({ continueOnError: true });
+ expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items).toMatchObject([{ status: "error" }, { status: "error" }, { status: "success" }]);
+ });
+
+ it("offers another recovery from the latest error after a resumed batch stops again", async () => {
+ const sqlError = structuredSqlError();
+ const sql = "INSERT INTO t VALUES (1);\nINSERT INTO t VALUES (2);\nINSERT INTO t VALUES (3);\nINSERT INTO t VALUES (4);\nINSERT INTO t VALUES (5)";
+ mocks.executeMultiWithProgress
+ .mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: 0 },
+ { columns: ["Error"], rows: [["first error"]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, execution_error: true, error: sqlError },
+ ])
+ .mockResolvedValueOnce([
+ { columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: 0 },
+ { columns: ["Error"], rows: [["second error"]], affected_rows: 0, execution_time_ms: 1, statement_index: 1, execution_error: true, error: sqlError },
+ ])
+ .mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1, statement_index: 0 }]);
+ const { useQueryStore } = await import("@/stores/queryStore");
+ const store = useQueryStore();
+ const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
+
+ await store.executeTabSql(tabId, sql);
+ await store.resumeBatchSql(tabId, "skip");
+ expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items).toMatchObject([{ status: "success" }, { status: "error" }, { status: "success" }, { status: "error" }, { status: "skipped" }]);
+
+ await expect(store.resumeBatchSql(tabId, "skip")).resolves.toBe(true);
+ expect(mocks.executeMultiWithProgress.mock.calls[2]?.[2]).toBe("INSERT INTO t VALUES (5)");
+ expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items).toMatchObject([{ status: "success" }, { status: "error" }, { status: "success" }, { status: "error" }, { status: "success" }]);
+ expect(store.tabs.find((item) => item.id === tabId)?.results?.map((result) => result.statement_index)).toEqual([0, 1, 2, 3, 4]);
+ });
+
it("marks every statement completed by a pipelined progress event", async () => {
const pendingExecution = deferred();
let reportProgress!: (progress: any) => void;
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index bea5121fbb..5d500cd3c3 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -60,6 +60,7 @@ import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshot
import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize";
import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey } from "@/lib/tabs/tabPresentation";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
+import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -102,6 +103,13 @@ const CANCEL_ACK_SETTLE_TIMEOUT_MS = 2_000;
const SAVED_SQL_EDITOR_POSITION_PERSIST_DELAY_MS = 500;
type CloseConfirmContext = "tab" | "batch" | "app";
+interface BatchSqlResumeOptions {
+ batch: BatchSqlExecution;
+ previousResults: QueryResult[];
+ startStatementIndex: number;
+ continueOnError: boolean;
+}
+
function hasHiddenPhysicalRowKey(databaseType: DatabaseType | undefined, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[]): boolean {
return hiddenPrimaryKeys.some((projection) => !usesSyntheticRowIdKey(databaseType, [projection.sourceName]));
}
@@ -273,7 +281,7 @@ const NON_STREAMING_BATCH_DATABASE_TYPES = new Set(["sqlserver", "
const liveBatchSqlExecutions = new WeakMap();
function cloneBatchSqlExecution(batch: BatchSqlExecution | undefined): BatchSqlExecution | undefined {
- return batch ? { ...batch, items: batch.items.map((item) => ({ ...item })) } : undefined;
+ return batch ? { ...batch, executionTarget: batch.executionTarget ? { ...batch.executionTarget } : undefined, items: batch.items.map((item) => ({ ...item })) } : undefined;
}
function batchSqlExecutionFor(tab: QueryTab, executionId: string): BatchSqlExecution | undefined {
@@ -286,7 +294,7 @@ function clearLiveBatchSqlExecution(tab: QueryTab, executionId: string) {
if (liveBatchSqlExecutions.get(tab)?.executionId === executionId) liveBatchSqlExecutions.delete(tab);
}
-function createBatchSqlExecution(executionId: string, editorSql: string, submittedSql: string, databaseType: DatabaseType | undefined, sourceOffset: number | undefined): BatchSqlExecution | undefined {
+function createBatchSqlExecution(executionId: string, editorSql: string, submittedSql: string, databaseType: DatabaseType | undefined, sourceOffset: number | undefined, executionTarget: MultiDbExecutionTarget): BatchSqlExecution | undefined {
const statements = databaseType === "mongodb" ? splitMongoCommandRanges(submittedSql).map(({ from, to, text }) => ({ from, to, sql: text })) : splitSqlStatementRanges(submittedSql, databaseType);
if (statements.length === 0) return undefined;
if (statements.length > 1 && databaseType && NON_STREAMING_BATCH_DATABASE_TYPES.has(databaseType)) return undefined;
@@ -299,6 +307,7 @@ function createBatchSqlExecution(executionId: string, editorSql: string, submitt
completed: 0,
total: statements.length,
startedAt: Date.now(),
+ executionTarget: { ...executionTarget },
items: statements.map((statement, statementIndex) => ({
statementIndex,
sql: statement.sql,
@@ -322,14 +331,15 @@ function applyBatchSqlProgress(
error?: BackendError;
},
continueOnError: boolean,
+ statementOffset = 0,
) {
const batch = batchSqlExecutionFor(tab, progress.executionId);
if (!batch) return;
- const previousCompleted = batch.completed;
- const item = batch.items[progress.statementIndex];
+ const statementIndex = statementOffset + progress.statementIndex;
+ const item = batch.items[statementIndex];
if (!item) return;
- if (progress.success && progress.completed > previousCompleted + 1) {
- for (let index = previousCompleted; index < progress.completed - 1; index += 1) {
+ if (progress.completed > 1) {
+ for (let index = statementOffset; index < statementOffset + progress.completed - 1; index += 1) {
const completedItem = batch.items[index];
if (completedItem && (completedItem.status === "pending" || completedItem.status === "running")) {
completedItem.status = "success";
@@ -341,9 +351,9 @@ function applyBatchSqlProgress(
item.affectedRows = progress.affectedRows;
item.errorDetails = progress.error;
item.error = progress.error ? translateBackendError(i18n.global.t, progress.error) : undefined;
- batch.completed = Math.max(batch.completed, progress.completed);
- if ((progress.success || continueOnError) && progress.completed < batch.total) {
- const next = batch.items[progress.completed];
+ batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
+ if ((progress.success || continueOnError) && progress.completed < progress.total) {
+ const next = batch.items[statementOffset + progress.completed];
if (next?.status === "pending") next.status = "running";
}
}
@@ -4225,6 +4235,7 @@ export const useQueryStore = defineStore("query", () => {
targetContext?: SqlExecutionTargetContext;
executionTarget?: MultiDbExecutionTarget;
onExecutionStarted?: () => void;
+ batchResume?: BatchSqlResumeOptions;
},
) {
const tab = findExecutionTab(id);
@@ -4262,9 +4273,16 @@ export const useQueryStore = defineStore("query", () => {
if (captureResultRun && tab.activeResultRunId) {
pendingResultRunRestores.set(executionId, tab.activeResultRunId);
}
- tab.batchSqlExecution = undefined;
- liveBatchSqlExecutions.delete(tab);
- const preserveResultDuringExecution = options?.preserveResultDuringExecution === true || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun);
+ const batchResume = options?.batchResume;
+ const continueOnBatchError = batchResume?.continueOnError ?? settingsStore.editorSettings.continueOnErrorOnBatch;
+ if (batchResume) {
+ tab.batchSqlExecution = prepareBatchSqlRecovery(batchResume.batch, executionId, batchResume.startStatementIndex);
+ liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
+ } else {
+ tab.batchSqlExecution = undefined;
+ liveBatchSqlExecutions.delete(tab);
+ }
+ const preserveResultDuringExecution = batchResume !== undefined || options?.preserveResultDuringExecution === true || (tab.mode === "query" && !!tab.activeResultRunId && !tab.resultAutoSave && !captureResultRun);
const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution;
if (!updateActiveResultRun) {
tab.activeResultRunId = undefined;
@@ -4299,11 +4317,12 @@ export const useQueryStore = defineStore("query", () => {
let useAgentResultSession = false;
let executionDispatched = false;
let producedResult = false;
- const executionConnectionId = options?.executionTarget?.connectionId ?? tab.connectionId;
+ const resumedExecutionTarget = batchResume?.batch.executionTarget;
+ const executionConnectionId = resumedExecutionTarget?.connectionId ?? options?.executionTarget?.connectionId ?? tab.connectionId;
try {
await waitForTabSessionReset(id);
const connStore = useConnectionStore();
- const executionTarget = options?.executionTarget;
+ const executionTarget = resumedExecutionTarget ?? options?.executionTarget;
const usesExternalExecutionTarget = !!executionTarget;
let conn = connStore.getConfig(executionConnectionId);
const parsedMongoCommands = conn?.db_type === "mongodb" ? splitMongoCommandRanges(sql) : undefined;
@@ -4329,15 +4348,26 @@ export const useQueryStore = defineStore("query", () => {
throw new Error("Namespace execution targets require a registered execution adapter.");
}
const databaseTargetContext = targetContext?.scope === "catalog" || targetContext?.scope === "database" ? targetContext : undefined;
- const executionCatalog = targetContext ? (targetContext.scope === "catalog" ? targetContext.catalog : undefined) : (executionTarget?.catalog ?? (tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog));
+ const executionCatalog = resumedExecutionTarget ? resumedExecutionTarget.catalog : targetContext ? (targetContext.scope === "catalog" ? targetContext.catalog : undefined) : (executionTarget?.catalog ?? (tab.mode === "data" ? tab.tableMeta?.catalog : tab.catalog));
const contextDatabase = databaseTargetContext?.database;
- const targetDatabase = targetContext?.scope === "connection" ? "" : (contextDatabase ?? executionTarget?.database ?? tab.database);
+ const targetDatabase = resumedExecutionTarget ? resumedExecutionTarget.database : targetContext?.scope === "connection" ? "" : (contextDatabase ?? executionTarget?.database ?? tab.database);
+ const targetSchema = resumedExecutionTarget ? resumedExecutionTarget.schema : targetContext?.scope === "connection" ? undefined : (databaseTargetContext?.schema ?? executionTarget?.schema ?? tab.schema);
const executionDatabase = dataTabExecutionDatabase(conn, targetDatabase, executionCatalog);
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type);
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs);
- const statementExecution = tab.mode === "query" ? createBatchSqlExecution(executionId, tab.sql, sql, effectiveDbType, options?.sourceOffset) : undefined;
- tab.batchSqlExecution = statementExecution && (tab.autoCommit !== false || statementExecution.total === 1) ? statementExecution : undefined;
- if (tab.batchSqlExecution) liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
+ if (!batchResume) {
+ const statementExecution =
+ tab.mode === "query"
+ ? createBatchSqlExecution(executionId, tab.sql, sql, effectiveDbType, options?.sourceOffset, {
+ connectionId: executionConnectionId,
+ catalog: executionCatalog,
+ database: targetDatabase,
+ schema: targetSchema,
+ })
+ : undefined;
+ tab.batchSqlExecution = statementExecution && (tab.autoCommit !== false || statementExecution.total === 1) ? statementExecution : undefined;
+ if (tab.batchSqlExecution) liveBatchSqlExecutions.set(tab, tab.batchSqlExecution);
+ }
queryExecutionLog("info", "previous-session-close:start", { traceId, elapsed: elapsed() });
await previousResultSessionClose;
queryExecutionLog("info", "previous-session-close:done", { traceId, elapsed: elapsed() });
@@ -4808,7 +4838,7 @@ export const useQueryStore = defineStore("query", () => {
sql,
});
const allResults: QueryResult[] = [];
- const continueOnError = settingsStore.editorSettings.continueOnErrorOnBatch;
+ const continueOnError = continueOnBatchError;
for (const request of elasticsearchRequests) {
const current = findExecutionTab(id);
if (current?.executionId !== executionId) break;
@@ -4926,7 +4956,7 @@ export const useQueryStore = defineStore("query", () => {
pageOffset = pagination.offset;
}
- const executionSchema = targetContext?.scope === "connection" ? undefined : connectionQueryExecutionSchema(conn, databaseTargetContext?.database ?? executionTarget?.database ?? tab.database, databaseTargetContext?.schema ?? executionTarget?.schema ?? tab.schema, tab.mode === "data");
+ const executionSchema = connectionQueryExecutionSchema(conn, targetDatabase, targetSchema, tab.mode === "data");
const frontendTimeoutSecs = frontendQueryTimeoutSecsForSql(sqlToExecute, effectiveDbType, queryTimeoutSecs);
const sourceLabelDatabase = targetDatabase || conn?.database;
const executionClientSessionId = options?.pagination?.clientSessionId ?? (tab.mode === "query" || tab.mode === "data" ? tabClientSessionId(tab) : undefined);
@@ -4977,7 +5007,7 @@ export const useQueryStore = defineStore("query", () => {
...(useOracleLobPreview ? { tableDataPreview: true } : {}),
timeoutSecs: queryTimeoutSecs,
catalog: executionCatalog,
- continueOnError: settingsStore.editorSettings.continueOnErrorOnBatch,
+ continueOnError: continueOnBatchError,
};
queryExecutionLog("info", "execute-multi:invoke", {
traceId,
@@ -4996,7 +5026,7 @@ export const useQueryStore = defineStore("query", () => {
(progress) => {
const current = findExecutionTab(id);
if (current?.executionId === executionId) {
- applyBatchSqlProgress(current, progress, settingsStore.editorSettings.continueOnErrorOnBatch);
+ applyBatchSqlProgress(current, progress, continueOnBatchError, batchResume?.startStatementIndex ?? 0);
}
},
executionSchema,
@@ -5004,7 +5034,10 @@ export const useQueryStore = defineStore("query", () => {
)
: api.executeMulti(executionConnectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
}
- const results = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset);
+ const results = offsetBatchQueryResultIndexes(
+ annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset),
+ batchResume?.startStatementIndex ?? 0,
+ );
reconcileBatchSqlResults(tab, executionId, results);
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;
@@ -5055,7 +5088,14 @@ export const useQueryStore = defineStore("query", () => {
const activeGroupResults = current.results;
const shouldAppendResult = !!options?.appendResult && !!current.result;
const shouldReplaceActiveResultInGroup = options?.replaceActiveResultInGroup === true && results.length === 1 && Array.isArray(activeGroupResults) && typeof activeGroupIndex === "number" && activeGroupIndex >= 0 && activeGroupIndex < activeGroupResults.length;
- if (shouldAppendResult) {
+ if (batchResume) {
+ const mergedResults = mergeBatchQueryResults(batchResume.previousResults, results);
+ const preferredResult = results.find((result) => isQueryExecutionErrorResult(result)) ?? results[results.length - 1] ?? mergedResults[mergedResults.length - 1];
+ const resultIndex = preferredResult ? mergedResults.indexOf(preferredResult) : 0;
+ current.results = mergedResults.length > 1 ? mergedResults : undefined;
+ current.activeResultIndex = mergedResults.length > 1 ? Math.max(0, resultIndex) : undefined;
+ current.result = mergedResults[Math.max(0, resultIndex)];
+ } else if (shouldAppendResult) {
if (results.length !== 1) throw new Error("Expected one result while loading the next segment");
if (options.pagination?.offset !== current.result!.rows.length) {
throw new Error("Ignoring a stale result segment whose offset no longer matches the loaded rows");
@@ -5083,8 +5123,8 @@ export const useQueryStore = defineStore("query", () => {
current.result = results[0];
}
producedResult = current.result !== undefined;
- current.resultBaseSql = shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
- current.resultEditorFingerprint = shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
+ current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
+ current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
current.resultSortedSql = resultSortedSql;
// Appended rows form one logical result starting at the original page.
// Keep the base page state so later table refresh/cache recovery does
@@ -5293,6 +5333,36 @@ export const useQueryStore = defineStore("query", () => {
return producedResult;
}
+ function dismissBatchSqlRecovery(id: string) {
+ const tab = findExecutionTab(id);
+ if (!tab?.batchSqlExecution || !batchSqlRecoveryState(tab)) return false;
+ tab.batchSqlExecution.recoveryDismissed = true;
+ return true;
+ }
+
+ async function resumeBatchSql(id: string, action: BatchSqlRecoveryAction) {
+ const tab = findExecutionTab(id);
+ const recovery = tab ? batchSqlRecoveryState(tab) : undefined;
+ const batch = tab?.batchSqlExecution;
+ if (!tab || !batch || !recovery) return false;
+
+ const startStatementIndex = action === "retry" ? recovery.failedStatementIndex : recovery.failedStatementIndex + 1;
+ const resumed = batchSqlRecoverySql(batch, startStatementIndex);
+ if (!resumed) return false;
+
+ const previousResults = tab.results?.slice() ?? (tab.result ? [tab.result] : []);
+ return await executeTabSql(id, resumed.sql, {
+ sourceOffset: resumed.sourceOffset,
+ preserveResultDuringExecution: true,
+ batchResume: {
+ batch: cloneBatchSqlExecution(batch)!,
+ previousResults,
+ startStatementIndex,
+ continueOnError: action === "skip-all",
+ },
+ });
+ }
+
async function explainTabSql(id: string, sql: string, databaseType?: DatabaseType, explainMode?: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab) return { ok: false as const, reason: "empty" as const };
@@ -6405,6 +6475,8 @@ export const useQueryStore = defineStore("query", () => {
executeCurrentTab,
executeCurrentSql,
executeTabSql,
+ dismissBatchSqlRecovery,
+ resumeBatchSql,
activeResultExecutionTarget,
getExecutionTab,
createMultiDbExecutionWorker,
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index e5e03111de..c9d4f52122 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -1,6 +1,6 @@
import type { BackendError } from "@/lib/backend/errorUtils";
import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/tauri";
-import type { MultiDbResultRunExecution } from "@/types/sqlExecution";
+import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution";
export type DatabaseType =
| "mysql"
@@ -771,7 +771,9 @@ export interface BatchSqlExecution {
completed: number;
total: number;
startedAt: number;
+ executionTarget?: MultiDbExecutionTarget;
finishedAt?: number;
+ recoveryDismissed?: boolean;
items: BatchStatementExecutionItem[];
}
diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs
index 7d0d3a7054..b5f5b06f15 100644
--- a/crates/dbx-core/src/query.rs
+++ b/crates/dbx-core/src/query.rs
@@ -237,6 +237,7 @@ impl ExecuteMultiResult {
}
}
+ #[cfg(test)]
fn execution_error_with_index(result: db::QueryResult, statement_index: usize) -> Self {
let error = error_from_query_result(&result);
Self {
@@ -2881,6 +2882,14 @@ fn mysql_batch_pool_error_action(db_type: Option, error: &str) ->
}
}
+fn mysql_batch_backend_error(action: PoolErrorAction, error: &str) -> crate::backend_error::BackendError {
+ if action == PoolErrorAction::Keep {
+ crate::backend_error::BackendError::from_sql_detail(error)
+ } else {
+ crate::backend_error::BackendError::from_legacy_backend(error)
+ }
+}
+
fn mysql_non_result_batch_end(
statements: &[String],
start: usize,
@@ -2964,7 +2973,7 @@ where
}
let failed_index = statement_index + completed;
let result = error_query_result(error.clone());
- let backend_error = crate::backend_error::BackendError::from_legacy_backend(&error);
+ let backend_error = mysql_batch_backend_error(action, &error);
report_execute_multi_progress(
progress,
failed_index,
@@ -3010,15 +3019,20 @@ where
Err(err) => {
let action = mysql_batch_pool_error_action(db_type, &err);
let result = error_query_result(err.clone());
+ let backend_error = mysql_batch_backend_error(action, &err);
report_execute_multi_progress(
progress,
statement_index,
statements.len(),
&result,
false,
- Some(crate::backend_error::BackendError::from_legacy_backend(&err)),
+ Some(backend_error.clone()),
);
- results.push(ExecuteMultiResult::execution_error_with_index(result, statement_index));
+ results.push(ExecuteMultiResult::execution_error_with_backend(
+ result,
+ Some(statement_index),
+ backend_error,
+ ));
// Statement errors are safe to collect, but connection-level failures leave
// the protocol state unusable and must still trigger pool cleanup.
if !should_continue_batch_after_error(continue_on_error, action) {
@@ -5925,7 +5939,7 @@ for line in sys.stdin:
success: false,
execution_time_ms: 0,
affected_rows: 0,
- error: Some(crate::backend_error::BackendError::from_legacy_backend("Duplicate entry")),
+ error: Some(crate::backend_error::BackendError::from_sql_detail("Duplicate entry")),
},
]
);
@@ -6038,6 +6052,7 @@ for line in sys.stdin:
assert_eq!(results[0].statement_index, Some(0));
assert_eq!(results[1].statement_index, Some(1));
assert!(results[1].execution_error);
+ assert_eq!(results[1].error.as_ref().map(|error| error.code()), Some("DBX-JDBC-4001"));
assert_eq!(
progress_events
.lock()
@@ -6392,6 +6407,7 @@ for line in sys.stdin:
assert_eq!(results.len(), 2);
assert_eq!(results.iter().map(|result| result.statement_index).collect::>(), vec![Some(0), Some(1)]);
assert!(results[1].execution_error);
+ assert_eq!(results[1].error.as_ref().map(|error| error.code()), Some("DBX-LEGACY-0001"));
assert_eq!(error_action, Some(PoolErrorAction::ReconnectAndRetry));
}
diff --git a/crates/dbx-core/tests/live_mysql57.rs b/crates/dbx-core/tests/live_mysql57.rs
index 12c1f0b05e..eb7f1ebc23 100644
--- a/crates/dbx-core/tests/live_mysql57.rs
+++ b/crates/dbx-core/tests/live_mysql57.rs
@@ -388,7 +388,7 @@ async fn live_mysql_single_call_public_route_preserves_all_result_sets() {
assert!(sql_error[0].execution_error);
let sql_error_message = sql_error[0].result.rows[0][0].as_str().expect("missing procedure error message");
assert!(sql_error_message.contains("does not exist"), "unexpected SQL error: {sql_error_message}");
- assert_eq!(sql_error[0].error.as_ref().map(|error| error.code()), Some("DBX-LEGACY-0001"));
+ assert_eq!(sql_error[0].error.as_ref().map(|error| error.code()), Some("DBX-JDBC-4001"));
assert!(read_only_error.into_legacy_string().to_ascii_lowercase().contains("read-only"));
assert_eq!(timeout_results.len(), 1);
assert!(timeout_results[0].execution_error);