mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
feat(query): resume failed SQL batches safely
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1442,6 +1442,12 @@ export default withEnglishFallback({
|
||||
noTable: "結果テーブルは返されませんでした",
|
||||
noSql: "SQLを取得できません",
|
||||
navigationHint: "クリックでSQLをプレビューし、ダブルクリックでエディター内を選択します。",
|
||||
recoveryPrompt: "ステートメント #{statement} が失敗し、残り {count} 件は未実行です。",
|
||||
stop: "停止",
|
||||
retry: "再試行",
|
||||
skipAndContinue: "スキップして続行",
|
||||
skipAll: "すべてのエラーをスキップ",
|
||||
skipAllHint: "続行し、このバッチで後続のエラーを自動的にスキップします",
|
||||
statuses: {
|
||||
pending: "待機中",
|
||||
running: "実行中",
|
||||
|
||||
@@ -1362,6 +1362,12 @@ export default withEnglishFallback({
|
||||
noTable: "반환된 결과 테이블이 없습니다",
|
||||
noSql: "SQL을 가져올 수 없습니다",
|
||||
navigationHint: "행을 클릭하여 SQL을 미리 보고 두 번 클릭하여 편집기에서 선택하세요.",
|
||||
recoveryPrompt: "구문 #{statement} 실행에 실패하여 나머지 {count}개 구문이 실행되지 않았습니다.",
|
||||
stop: "중지",
|
||||
retry: "다시 시도",
|
||||
skipAndContinue: "건너뛰고 계속",
|
||||
skipAll: "모든 오류 건너뛰기",
|
||||
skipAllHint: "계속 실행하고 이 배치의 이후 오류를 자동으로 건너뜁니다",
|
||||
statuses: {
|
||||
pending: "대기 중",
|
||||
running: "실행 중",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1405,6 +1405,12 @@ export default withEnglishFallback({
|
||||
noTable: "未返回结果表",
|
||||
noSql: "无法获取 SQL",
|
||||
navigationHint: "单击预览对应 SQL,双击在编辑器中聚焦并选中。",
|
||||
recoveryPrompt: "语句 #{statement} 执行失败,剩余 {count} 条未执行。",
|
||||
stop: "停止",
|
||||
retry: "重试",
|
||||
skipAndContinue: "跳过并继续",
|
||||
skipAll: "全部忽略",
|
||||
skipAllHint: "继续执行,并自动跳过当前批次后续错误",
|
||||
statuses: {
|
||||
pending: "等待",
|
||||
running: "执行中",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user