feat(query): resume failed SQL batches safely

This commit is contained in:
zipg
2026-08-22 16:37:24 +08:00
committed by GitHub
parent 9774a194ec
commit 0559f9f76e
16 changed files with 531 additions and 33 deletions
@@ -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({
<div class="h-full rounded-full bg-primary transition-[width] duration-200" :style="{ width: `${batchExecutionPercent}%` }" />
</div>
</div>
<div v-if="batchRecovery" class="flex shrink-0 items-center gap-3 border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<CircleAlert class="h-4 w-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">
{{ t("executionSummary.recoveryPrompt", { statement: batchRecovery.failedStatementIndex + 1, count: batchRecovery.remainingStatementCount }) }}
</span>
<div class="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="dismissBatchRecovery">
<CircleStop class="h-3.5 w-3.5" />
{{ t("executionSummary.stop") }}
</Button>
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="resumeBatchExecution('retry')">
<RotateCcw class="h-3.5 w-3.5" />
{{ t("executionSummary.retry") }}
</Button>
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="resumeBatchExecution('skip')">
<SkipForward class="h-3.5 w-3.5" />
{{ t("executionSummary.skipAndContinue") }}
</Button>
<LightTooltip :text="t('executionSummary.skipAllHint')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="resumeBatchExecution('skip-all')">
<ListX class="h-3.5 w-3.5" />
{{ t("executionSummary.skipAll") }}
</Button>
</LightTooltip>
</div>
</div>
<div class="grid shrink-0 grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<div>{{ t("executionSummary.statement") }}</div>
<div>{{ t("executionSummary.sql") }}</div>
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -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",
+6
View File
@@ -1442,6 +1442,12 @@ export default withEnglishFallback({
noTable: "結果テーブルは返されませんでした",
noSql: "SQLを取得できません",
navigationHint: "クリックでSQLをプレビューし、ダブルクリックでエディター内を選択します。",
recoveryPrompt: "ステートメント #{statement} が失敗し、残り {count} 件は未実行です。",
stop: "停止",
retry: "再試行",
skipAndContinue: "スキップして続行",
skipAll: "すべてのエラーをスキップ",
skipAllHint: "続行し、このバッチで後続のエラーを自動的にスキップします",
statuses: {
pending: "待機中",
running: "実行中",
+6
View File
@@ -1362,6 +1362,12 @@ export default withEnglishFallback({
noTable: "반환된 결과 테이블이 없습니다",
noSql: "SQL을 가져올 수 없습니다",
navigationHint: "행을 클릭하여 SQL을 미리 보고 두 번 클릭하여 편집기에서 선택하세요.",
recoveryPrompt: "구문 #{statement} 실행에 실패하여 나머지 {count}개 구문이 실행되지 않았습니다.",
stop: "중지",
retry: "다시 시도",
skipAndContinue: "건너뛰고 계속",
skipAll: "모든 오류 건너뛰기",
skipAllHint: "계속 실행하고 이 배치의 이후 오류를 자동으로 건너뜁니다",
statuses: {
pending: "대기 중",
running: "실행 중",
+6
View File
@@ -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",
+6
View File
@@ -1405,6 +1405,12 @@ export default withEnglishFallback({
noTable: "未返回结果表",
noSql: "无法获取 SQL",
navigationHint: "单击预览对应 SQL,双击在编辑器中聚焦并选中。",
recoveryPrompt: "语句 #{statement} 执行失败,剩余 {count} 条未执行。",
stop: "停止",
retry: "重试",
skipAndContinue: "跳过并继续",
skipAll: "全部忽略",
skipAllHint: "继续执行,并自动跳过当前批次后续错误",
statuses: {
pending: "等待",
running: "执行中",
+6
View File
@@ -1421,6 +1421,12 @@ export default withEnglishFallback({
noTable: "未回傳結果表",
noSql: "無法取得 SQL",
navigationHint: "按一下預覽 SQL,按兩下在編輯器中聚焦並選取。",
recoveryPrompt: "語句 #{statement} 執行失敗,剩餘 {count} 條未執行。",
stop: "停止",
retry: "重試",
skipAndContinue: "略過並繼續",
skipAll: "全部忽略",
skipAllHint: "繼續執行,並自動略過目前批次後續錯誤",
statuses: {
pending: "等待",
running: "執行中",
@@ -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]]);
});
});
@@ -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<QueryTab, "autoCommit" | "batchSqlExecution" | "isExecuting">): 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];
}
@@ -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<any[]>();
let reportProgress!: (progress: any) => void;
+99 -27
View File
@@ -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<DatabaseType>(["sqlserver", "
const liveBatchSqlExecutions = new WeakMap<QueryTab, BatchSqlExecution>();
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,
+3 -1
View File
@@ -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[];
}
+20 -4
View File
@@ -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<DatabaseType>, 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<_>>(), 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));
}
+1 -1
View File
@@ -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);