From 0559f9f76e3d6367bc6b70326708fbf6be2584d2 Mon Sep 17 00:00:00 2001 From: zipg Date: Sat, 22 Aug 2026 16:37:24 +0800 Subject: [PATCH] feat(query): resume failed SQL batches safely --- .../src/components/layout/ContentArea.vue | 41 +++++ apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/ko.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + .../__tests__/query/batchSqlRecovery.spec.ts | 102 ++++++++++++ .../desktop/src/lib/query/batchSqlRecovery.ts | 69 ++++++++ .../queryStore.multiStatementError.spec.ts | 148 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 126 +++++++++++---- apps/desktop/src/types/database.ts | 4 +- crates/dbx-core/src/query.rs | 24 ++- crates/dbx-core/tests/live_mysql57.rs | 2 +- 16 files changed, 531 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/query/batchSqlRecovery.spec.ts create mode 100644 apps/desktop/src/lib/query/batchSqlRecovery.ts 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.recoveryPrompt", { statement: batchRecovery.failedStatementIndex + 1, count: batchRecovery.remainingStatementCount }) }} + +
+ + + + + + +
+
{{ 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);