feat(query): show live statement execution progress

This commit is contained in:
greathaoqi 2026-07-30 00:44:10 +08:00 committed by GitHub
parent 1774126f76
commit ed018dbc77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 974 additions and 85 deletions

View File

@ -635,7 +635,7 @@ function requestExecuteFromView(currentView: EditorViewType, cursorPos: number,
if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker || !hasMultipleExecutionTargets(doc, props.databaseType, parameterOptions)) {
const preferredKind = settingsStore.editorSettings.executeMode === "current" ? "cursor" : "all";
const candidate = candidates.find((item) => item.kind === preferredKind) ?? candidates[0];
emitExecutionRequest(candidate.sql, options.openInNewResultTab);
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab);
return true;
}
closePicker();
@ -723,6 +723,22 @@ function previewStatementRange(range: { from: number; to: number } | null) {
});
}
function focusStatementRange(range: { from: number; to: number } | null) {
const currentView = view.value;
if (!range || !currentView || !editorViewModule || !setResultSourceRangeEffect) {
setResultSourceRange(null);
return;
}
const from = Math.max(0, Math.min(range.from, currentView.state.doc.length));
const to = Math.max(from, Math.min(range.to, currentView.state.doc.length));
if (from === to) return;
currentView.dispatch({
selection: { anchor: from, head: to },
effects: [setResultSourceRangeEffect.of({ from, to }), editorViewModule.EditorView.scrollIntoView(from, { y: "center" })],
});
currentView.focus();
}
function onPickerActiveIndexChange(index: number) {
pickerActiveIndex.value = index;
const candidate = pickerCandidates.value[index];
@ -732,8 +748,9 @@ function onPickerActiveIndexChange(index: number) {
}
function onPickerConfirm(candidate: SqlExecutionCandidate) {
const currentView = view.value;
closePicker();
emit("execute", candidate.sql);
emit("execute", currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql);
}
function closePicker() {
@ -1257,7 +1274,7 @@ function executeSqlStatementFromGutter(currentView: EditorViewType, line: { from
event.stopPropagation();
// Gutter play is always scoped to the statement/command for that line, even
// when the main editor execute action would run the full document.
emit("execute", statementRange.sql);
emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, statementRange));
currentView.focus();
return true;
}
@ -1566,6 +1583,16 @@ function sqlExecutionSnapshotFromView(currentView: EditorViewType): SqlExecution
};
}
function sqlExecutionSnapshotForRange(currentView: EditorViewType, range: Pick<SqlExecutionCandidate, "sql" | "from" | "to">): SqlExecutionSnapshot {
return {
fullSql: currentView.state.doc.toString(),
selectedSql: range.sql,
cursorPos: currentView.state.selection.main.head,
selectionFrom: range.from,
selectionTo: range.to,
};
}
function identifierRangeAt(sql: string, pos: number): { from: number; to: number; text: string } | null {
const isIdentifierChar = (ch: string | undefined) => !!ch && /[\w$.]/.test(ch);
if (!isIdentifierChar(sql[pos]) && !isIdentifierChar(sql[pos - 1])) return null;
@ -3573,7 +3600,7 @@ onMounted(async () => {
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount;
return other instanceof StatementExecutionStateMarker && other.marker.status === this.marker.status && other.marker.successCount === this.marker.successCount && other.marker.errorCount === this.marker.errorCount && other.marker.runningCount === this.marker.runningCount;
}
}
@ -3586,7 +3613,14 @@ onMounted(async () => {
}
eq(other: import("@codemirror/view").GutterMarker): boolean {
return other instanceof StatementGutterMarker && other.canExecute === this.canExecute && other.marker?.status === this.marker?.status && other.marker?.successCount === this.marker?.successCount && other.marker?.errorCount === this.marker?.errorCount;
return (
other instanceof StatementGutterMarker &&
other.canExecute === this.canExecute &&
other.marker?.status === this.marker?.status &&
other.marker?.successCount === this.marker?.successCount &&
other.marker?.errorCount === this.marker?.errorCount &&
other.marker?.runningCount === this.marker?.runningCount
);
}
toDOM() {
@ -3601,6 +3635,7 @@ onMounted(async () => {
function statementExecutionMarkerTitle(marker: StatementExecutionMarker) {
const parts = [];
if ((marker.runningCount ?? 0) > 0) parts.push(t("editor.statementExecutionRunning", { count: marker.runningCount }));
if (marker.successCount > 0) parts.push(t("editor.statementExecutionSucceeded", { count: marker.successCount }));
if (marker.errorCount > 0) parts.push(t("editor.statementExecutionFailed", { count: marker.errorCount }));
return parts.join(", ");
@ -4569,6 +4604,7 @@ defineExpose({
requestExecute,
requestExecuteInNewResultTab,
pasteClipboardAsSqlInCondition,
focusStatementRange,
previewStatementRange,
refreshCompletionCache,
});
@ -4681,6 +4717,11 @@ defineExpose({
color: rgb(4 120 87);
}
:deep(.cm-statement-execution-marker--running) {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
:deep(.cm-statement-execution-marker--error) {
background: rgb(239 68 68 / 0.1);
color: rgb(185 28 28);
@ -4772,6 +4813,10 @@ defineExpose({
background: rgb(5 150 105);
}
:deep(.cm-statement-execution-badge--running) {
background: var(--primary);
}
:deep(.cm-statement-execution-badge--error) {
background: rgb(220 38 38);
}
@ -4782,6 +4827,16 @@ defineExpose({
height: 75%;
}
:deep(.cm-statement-execution-spinner) {
animation: dbx-statement-execution-spin 0.8s linear infinite;
}
@keyframes dbx-statement-execution-spin {
to {
transform: rotate(360deg);
}
}
:deep(.cm-foldMarker-svg) {
display: inline-flex;
align-items: center;

View File

@ -70,7 +70,7 @@ import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGr
import { useToast } from "@/composables/useToast";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { databaseDisplayNameForTab, executionSummaryItems, queryResultExecutionSql, resultGridCacheKey, resultRunItems, resultSourceRange, resultSqlForGrid, statementExecutionMarkers, tabularResultItems, type ExecutionSummaryItem } from "@/lib/tabs/tabPresentation";
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { isTableDataEditable } from "@/lib/table/tableEditing";
@ -368,6 +368,7 @@ const activeStatementExecutionMarkers = computed(() =>
activeEffectiveDatabaseType.value,
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
props.activeTab.resultEditorFingerprint ?? "",
props.activeTab.batchSqlExecution,
),
);
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
@ -401,6 +402,11 @@ watch(
);
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
const batchExecutionProgress = computed(() => props.activeTab.batchSqlExecution);
const batchExecutionPercent = computed(() => {
const progress = batchExecutionProgress.value;
return progress?.total ? Math.round((progress.completed / progress.total) * 100) : 0;
});
const hasTabularResult = computed(() => {
if (props.activeTab.result?.columns.length) return true;
return visibleResultItems.value.length > 0;
@ -851,6 +857,21 @@ function selectResultItem(item: (typeof visibleResultItems.value)[number]) {
});
}
function executionSummaryItemRange(item: ExecutionSummaryItem) {
if (typeof item.sourceFrom === "number" && typeof item.sourceTo === "number" && item.sql && props.activeTab.sql.slice(item.sourceFrom, item.sourceTo) === item.sql) {
return { from: item.sourceFrom, to: item.sourceTo };
}
return item.result ? resultSourceRange(props.activeTab.sql, item.result, item.statementIndex, activeEffectiveDatabaseType.value) : undefined;
}
function previewExecutionSummaryItem(item: ExecutionSummaryItem) {
queryEditorRef.value?.previewStatementRange(executionSummaryItemRange(item) ?? null);
}
function focusExecutionSummaryItem(item: ExecutionSummaryItem) {
queryEditorRef.value?.focusStatementRange(executionSummaryItemRange(item) ?? null);
}
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;
@ -1296,37 +1317,68 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
<div v-else-if="activeOutputView === 'summary'" class="flex-1 min-h-0 overflow-auto bg-background">
<div v-if="activeTab.isExecuting" class="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{{ t("executionSummary.executing") }}
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 v-if="activeTab.isExecuting" class="mr-2 h-4 w-4 animate-spin" />
<template v-if="activeTab.isExecuting">{{ t("executionSummary.executing") }}</template>
<template v-else>{{ t("executionSummary.empty") }}</template>
</div>
<div v-else-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("executionSummary.empty") }}
</div>
<div v-else>
<div v-else class="min-w-[46rem]">
<div v-if="batchExecutionProgress" class="sticky top-0 z-10 border-b bg-background/95 px-3 py-2 backdrop-blur">
<div class="mb-1.5 flex items-center gap-3 text-xs">
<span class="font-medium">{{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }}</span>
<span class="tabular-nums text-muted-foreground">{{ batchExecutionProgress.completed }} / {{ batchExecutionProgress.total }}</span>
<span class="ml-auto tabular-nums text-muted-foreground">{{ batchExecutionPercent }}%</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
<div class="h-full rounded-full bg-primary transition-[width] duration-200" :style="{ width: `${batchExecutionPercent}%` }" />
</div>
</div>
<div class="overflow-hidden border-b">
<div class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
<div class="grid 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.type") }}</div>
<div class="text-right">{{ t("executionSummary.rows") }}</div>
<div>{{ t("executionSummary.sql") }}</div>
<div>{{ t("executionSummary.status") }}</div>
<div class="text-right">{{ t("executionSummary.affected") }}</div>
<div class="text-right">{{ t("executionSummary.time") }}</div>
</div>
<div v-for="item in summaryItems" :key="item.index" class="grid grid-cols-[4rem_1fr_8rem_8rem_7rem] items-center border-b px-3 py-2 text-xs last:border-b-0">
<div class="font-mono text-muted-foreground">#{{ item.index + 1 }}</div>
<div class="flex min-w-0 items-center gap-2">
<span class="inline-flex h-5 items-center rounded-full border px-2 text-[10px]" :class="item.isError ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'">
{{ item.isError ? t("executionSummary.error") : t("executionSummary.success") }}
</span>
<span class="truncate">
{{ item.hasTabularResult ? t("executionSummary.returnedTable", { count: item.returnedColumns }) : t("executionSummary.noTable") }}
<button
v-for="item in summaryItems"
:key="item.statementIndex"
type="button"
class="grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs transition-colors last:border-b-0 hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
:title="item.error || item.sql"
@click="previewExecutionSummaryItem(item)"
@dblclick="focusExecutionSummaryItem(item)"
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
>
<div class="font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
<div class="min-w-0">
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
<div v-if="item.error" class="mt-0.5 truncate text-[11px] text-destructive">{{ item.error }}</div>
</div>
<div>
<span
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
:class="{
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
}"
>
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
<SquareDashed v-else class="h-3 w-3" />
{{ t(`executionSummary.statuses.${item.status}`) }}
</span>
</div>
<div class="text-right tabular-nums">{{ item.returnedRows.toLocaleString() }}</div>
<div class="text-right tabular-nums">{{ item.affectedRows.toLocaleString() }}</div>
<div class="text-right tabular-nums">{{ item.executionTimeMs }}ms</div>
</div>
<div class="text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.affectedRows.toLocaleString() }}</div>
<div class="text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
</button>
</div>
<div class="px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
</div>
</div>

View File

@ -147,6 +147,29 @@ describe("useSqlExecution", () => {
expect(executeCurrentSql).toHaveBeenCalledWith(selectedSql, { sourceOffset: selectionFrom });
});
it("opens the execution summary for a multi-statement batch", async () => {
const sql = "SELECT 1;\nSELECT 2;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("mysql"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
});
it("forwards execute-in-new-result-tab intent to the query store", async () => {
const sql = "SELECT * FROM users";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });

View File

@ -218,7 +218,8 @@ export function useSqlExecution(deps: {
deps.onMissingDatabase?.();
return;
}
deps.activeOutputView.value = "result";
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType).length;
deps.activeOutputView.value = statementCount > 1 ? "summary" : "result";
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = executionDatabaseType === "redis";

View File

@ -707,6 +707,7 @@ export default {
},
editor: {
duckdbDraining: "The previous DuckDB query is still stopping. Please try again shortly.",
statementExecutionRunning: "{count} statement running | {count} statements running",
statementExecutionSucceeded: "{count} statement succeeded | {count} statements succeeded",
statementExecutionFailed: "{count} statement failed | {count} statements failed",
pressToExecute: "Press {mod}+Enter to execute",
@ -966,7 +967,10 @@ export default {
executionSummary: {
empty: "No summary",
executing: "Executing...",
finished: "Execution finished",
statement: "Statement",
sql: "SQL",
status: "Status",
type: "Type",
rows: "Rows",
affected: "Affected",
@ -975,6 +979,16 @@ export default {
error: "Error",
returnedTable: "Returned a {count}-column result table",
noTable: "No result table returned",
noSql: "SQL unavailable",
navigationHint: "Click a row to preview its SQL; double-click to focus it in the editor.",
statuses: {
pending: "Pending",
running: "Running",
success: "Success",
error: "Error",
skipped: "Not run",
cancelled: "Cancelled",
},
},
chart: {
title: "Chart",

View File

@ -845,6 +845,7 @@ export default withEnglishFallback({
currentCommand: "Current command",
allCommands: "All commands",
},
statementExecutionRunning: "{count} sentencias en ejecución",
statementExecutionSucceeded: "{count} sentencias exitosas",
statementExecutionFailed: "{count} sentencias fallidas",
delimitedList: {
@ -914,7 +915,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "Sin resumen",
executing: "Ejecutando...",
finished: "Ejecución finalizada",
statement: "Sentencia",
sql: "SQL",
status: "Estado",
type: "Tipo",
rows: "Filas",
affected: "Afectadas",
@ -923,6 +927,16 @@ export default withEnglishFallback({
error: "Error",
returnedTable: "Devolvió una tabla de resultados de {count} columnas",
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.",
statuses: {
pending: "Pendiente",
running: "Ejecutando",
success: "Éxito",
error: "Error",
skipped: "No ejecutada",
cancelled: "Cancelada",
},
},
chart: {
title: "Gráfico",

View File

@ -843,6 +843,7 @@ export default withEnglishFallback({
currentCommand: "Current command",
allCommands: "All commands",
},
statementExecutionRunning: "{count} istruzioni in esecuzione",
statementExecutionSucceeded: "{count} istruzioni eseguite con successo",
statementExecutionFailed: "{count} istruzioni fallite",
delimitedList: {
@ -912,7 +913,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "Nessun riepilogo",
executing: "Esecuzione...",
finished: "Esecuzione completata",
statement: "Istruzione",
sql: "SQL",
status: "Stato",
type: "Tipo",
rows: "Righe",
affected: "Interessate",
@ -921,6 +925,16 @@ export default withEnglishFallback({
error: "Errore",
returnedTable: "Restituita una tabella con {count} colonne",
noTable: "Nessuna tabella restituita",
noSql: "SQL non disponibile",
navigationHint: "Fai clic per visualizzare l'SQL; fai doppio clic per selezionarlo nell'editor.",
statuses: {
pending: "In attesa",
running: "In esecuzione",
success: "Riuscita",
error: "Errore",
skipped: "Non eseguita",
cancelled: "Annullata",
},
},
chart: {
title: "Grafico",

View File

@ -843,6 +843,7 @@ export default withEnglishFallback({
allCommands: "All commands",
},
selectDatabaseRequired: "先にデータベースを選択してください",
statementExecutionRunning: "{count} 件のステートメントを実行中",
statementExecutionSucceeded: "{count} 件のステートメントが成功しました",
statementExecutionFailed: "{count} 件のステートメントが失敗しました",
delimitedList: {
@ -913,7 +914,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "サマリーはありません",
executing: "実行中...",
finished: "実行完了",
statement: "ステートメント",
sql: "SQL",
status: "状態",
type: "タイプ",
rows: "行数",
affected: "影響行数",
@ -922,6 +926,16 @@ export default withEnglishFallback({
error: "エラー",
returnedTable: "{count}列の結果テーブルを返しました",
noTable: "結果テーブルは返されませんでした",
noSql: "SQLを取得できません",
navigationHint: "クリックでSQLをプレビューし、ダブルクリックでエディター内を選択します。",
statuses: {
pending: "待機中",
running: "実行中",
success: "成功",
error: "エラー",
skipped: "未実行",
cancelled: "キャンセル済み",
},
},
chart: {
title: "グラフ",

View File

@ -698,6 +698,7 @@ export default withEnglishFallback({
},
editor: {
duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.",
statementExecutionRunning: "{count}개 구문 실행 중",
statementExecutionSucceeded: "{count}개 구문 성공",
statementExecutionFailed: "{count}개 구문 실패",
pressToExecute: "{mod}+Enter를 눌러 실행",
@ -955,7 +956,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "요약 없음",
executing: "실행 중...",
finished: "실행 완료",
statement: "구문",
sql: "SQL",
status: "상태",
type: "유형",
rows: "행",
affected: "영향 받은 행",
@ -964,6 +968,16 @@ export default withEnglishFallback({
error: "오류",
returnedTable: "{count}열 결과 테이블을 반환했습니다",
noTable: "반환된 결과 테이블이 없습니다",
noSql: "SQL을 가져올 수 없습니다",
navigationHint: "행을 클릭하여 SQL을 미리 보고 두 번 클릭하여 편집기에서 선택하세요.",
statuses: {
pending: "대기 중",
running: "실행 중",
success: "성공",
error: "오류",
skipped: "실행 안 됨",
cancelled: "취소됨",
},
},
chart: {
title: "차트",

View File

@ -844,6 +844,7 @@ export default withEnglishFallback({
currentCommand: "Current command",
allCommands: "All commands",
},
statementExecutionRunning: "{count} instrução(ões) em execução",
statementExecutionSucceeded: "{count} instrução(ões) executada(s) com sucesso",
statementExecutionFailed: "{count} instrução(ões) com falha",
delimitedList: {
@ -914,7 +915,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "Nenhum resumo",
executing: "Executando...",
finished: "Execução concluída",
statement: "Instrução",
sql: "SQL",
status: "Status",
type: "Tipo",
rows: "Linhas",
affected: "Afetadas",
@ -923,6 +927,16 @@ export default withEnglishFallback({
error: "Erro",
returnedTable: "Retornou uma tabela de resultado com {count} colunas",
noTable: "Nenhuma tabela de resultado retornada",
noSql: "SQL indisponível",
navigationHint: "Clique para visualizar o SQL; clique duas vezes para selecioná-lo no editor.",
statuses: {
pending: "Pendente",
running: "Executando",
success: "Sucesso",
error: "Erro",
skipped: "Não executada",
cancelled: "Cancelada",
},
},
chart: {
title: "Gráfico",

View File

@ -708,6 +708,7 @@ export default withEnglishFallback({
},
editor: {
duckdbDraining: "上一条 DuckDB 查询仍在停止,请稍后重试。",
statementExecutionRunning: "{count} 条语句正在执行",
statementExecutionSucceeded: "{count} 条语句成功",
statementExecutionFailed: "{count} 条语句失败",
pressToExecute: "按 {mod}+Enter 执行查询",
@ -967,7 +968,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "暂无摘要",
executing: "正在执行...",
finished: "执行完成",
statement: "语句",
sql: "SQL",
status: "状态",
type: "类型",
rows: "返回行",
affected: "影响行",
@ -976,6 +980,16 @@ export default withEnglishFallback({
error: "错误",
returnedTable: "返回 {count} 列结果表",
noTable: "未返回结果表",
noSql: "无法获取 SQL",
navigationHint: "单击预览对应 SQL双击在编辑器中聚焦并选中。",
statuses: {
pending: "等待",
running: "执行中",
success: "成功",
error: "失败",
skipped: "未执行",
cancelled: "已取消",
},
},
chart: {
title: "图表",

View File

@ -843,6 +843,7 @@ export default withEnglishFallback({
currentCommand: "目前命令",
allCommands: "全部命令",
},
statementExecutionRunning: "{count} 條陳述式執行中",
statementExecutionSucceeded: "{count} 條陳述式成功",
statementExecutionFailed: "{count} 條陳述式失敗",
delimitedList: {
@ -913,7 +914,10 @@ export default withEnglishFallback({
executionSummary: {
empty: "無摘要",
executing: "執行中...",
finished: "執行完成",
statement: "語句",
sql: "SQL",
status: "狀態",
type: "類型",
rows: "列數",
affected: "影響",
@ -922,6 +926,16 @@ export default withEnglishFallback({
error: "錯誤",
returnedTable: "回傳 {count} 欄的結果表",
noTable: "未回傳結果表",
noSql: "無法取得 SQL",
navigationHint: "按一下預覽 SQL按兩下在編輯器中聚焦並選取。",
statuses: {
pending: "等待",
running: "執行中",
success: "成功",
error: "失敗",
skipped: "未執行",
cancelled: "已取消",
},
},
chart: {
title: "圖表",

View File

@ -37,4 +37,17 @@ describe("CodeMirror statement gutter marker", () => {
expect(marker.querySelectorAll(".cm-statement-execution-badge")).toHaveLength(0);
expect(marker.getAttribute("aria-label")).toBe("1 statement failed");
});
it("renders an animated running marker", () => {
const marker = createStatementGutterMarkerDom({
canExecute: false,
executeLabel: "Execute SQL",
status: "running",
statusLabel: "1 statement running",
});
expect(marker.classList.contains("cm-statement-execution-marker--running")).toBe(true);
expect(marker.querySelector(".cm-statement-execution-spinner")).not.toBeNull();
expect(marker.getAttribute("aria-label")).toBe("1 statement running");
});
});

View File

@ -27,6 +27,18 @@ describe("QueryEditor execution routing", () => {
expect(executeModeBranch).toBeGreaterThan(selectionBranch);
});
it("preserves the source range when executing a current/all candidate without a manual selection", () => {
expect(queryEditorSource).toContain("emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, candidate), options.openInNewResultTab)");
expect(queryEditorSource).toContain("currentView ? sqlExecutionSnapshotForRange(currentView, candidate) : candidate.sql");
expect(queryEditorSource).toContain("selectionFrom: range.from");
expect(queryEditorSource).toContain("selectionTo: range.to");
});
it("preserves the source range when executing from the statement gutter", () => {
expect(queryEditorSource).toContain("emitExecutionRequest(sqlExecutionSnapshotForRange(currentView, statementRange))");
expect(queryEditorSource).not.toContain('emit("execute", statementRange.sql)');
});
it("lets the shortcut skip the picker without affecting other execution entry points", () => {
// The picker guard must also honor the shortcut's bypass flag, otherwise Ctrl+Enter would keep popping the dialog.
expect(queryEditorSource).toContain("if (options.bypassPicker || !settingsStore.editorSettings.showExecutionTargetPicker");

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionGroupDisplayName, middleEllipsis, queryResultBaseSql, queryResultExecutionSql, resultSourceRange, statementExecutionMarkers, tabTooltipLines, tabularResultItems } from "@/lib/tabs/tabPresentation";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const translations: Record<string, string> = {
@ -224,6 +225,47 @@ describe("query result source ranges", () => {
});
describe("statement execution markers", () => {
it("renders a live marker for a single statement", () => {
const sql = "SELECT 1";
expect(
statementExecutionMarkers(sql, undefined, "mysql", sql, "", {
executionId: "run-single",
submittedSql: sql,
editorFingerprint: sqlTextFingerprint(sql),
sourceOffset: 0,
completed: 0,
total: 1,
startedAt: 1,
items: [{ statementIndex: 0, sql, from: 0, to: sql.length, status: "running" }],
}),
).toEqual([{ from: 0, status: "running", successCount: 0, errorCount: 0, runningCount: 1 }]);
});
it("renders running and completed markers from live batch state", () => {
const sql = "SELECT 1;\nSELECT 2;\nSELECT 3;";
const secondFrom = sql.indexOf("SELECT 2");
const thirdFrom = sql.indexOf("SELECT 3");
expect(
statementExecutionMarkers(sql, undefined, "sqlite", sql, "", {
executionId: "run-1",
submittedSql: sql,
editorFingerprint: sqlTextFingerprint(sql),
sourceOffset: 0,
completed: 1,
total: 3,
startedAt: 1,
items: [
{ statementIndex: 0, sql: "SELECT 1", from: 0, to: 8, status: "success" },
{ statementIndex: 1, sql: "SELECT 2", from: secondFrom, to: secondFrom + 8, status: "running" },
{ statementIndex: 2, sql: "SELECT 3", from: thirdFrom, to: thirdFrom + 8, status: "pending" },
],
}),
).toEqual([
{ from: 0, status: "success", successCount: 1, errorCount: 0 },
{ from: secondFrom, status: "running", successCount: 0, errorCount: 0, runningCount: 1 },
]);
});
it("projects explicit statement indexes to current editor lines", () => {
const sql = "SELECT 1;\nSELECT * FROM missing;\nSELECT 3;";
const secondFrom = sql.indexOf("SELECT *");
@ -243,7 +285,7 @@ describe("statement execution markers", () => {
]);
});
it("omits unindexed query-level errors and single-statement executions", () => {
it("omits unindexed query-level errors and legacy single-statement results without live state", () => {
expect(statementExecutionMarkers("SELECT 1; SELECT 2;", [{ columns: ["Error"], rows: [["pool failed"]], affected_rows: 0, execution_time_ms: 1, execution_error: true }], "mysql", "SELECT 1; SELECT 2;")).toEqual([]);
expect(statementExecutionMarkers("SELECT 1", [{ columns: ["value"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, statement_index: 0, sourceStatement: "SELECT 1", sourceFrom: 0, sourceTo: 8 }], "mysql", "SELECT 1")).toEqual([]);
});

View File

@ -107,6 +107,43 @@ describe("tab result cache statement execution metadata", () => {
]);
});
it("preserves result-run statement execution state", () => {
const encoded = encodeTabResultSnapshot({
resultRuns: [
{
id: "run-1",
title: "Run 1",
sequence: 1,
sql: "SELECT 1; SELECT bad",
createdAt: 1,
batchSqlExecution: {
executionId: "exec-1",
submittedSql: "SELECT 1; SELECT bad",
editorFingerprint: "20:0123456789abcdef",
sourceOffset: 0,
completed: 2,
total: 2,
startedAt: 1,
finishedAt: 2,
items: [
{ statementIndex: 0, sql: "SELECT 1", from: 0, to: 8, status: "success", executionTimeMs: 1 },
{ statementIndex: 1, sql: "SELECT bad", from: 10, to: 20, status: "error", error: "failed", executionTimeMs: 1 },
],
},
},
],
activeResultRunId: "run-1",
cachedAt: 1,
});
expect(decodeTabResultSnapshot(encoded)?.resultRuns?.[0]?.batchSqlExecution).toMatchObject({
executionId: "exec-1",
completed: 2,
total: 2,
items: [{ status: "success" }, { status: "error", error: "failed" }],
});
});
it("restores multi-result, pagination, local-sort, and editable metadata fixtures", () => {
const restored = decodeTabResultSnapshot(encodeTabResultSnapshot(queryResultLifecycleSnapshot()));

View File

@ -863,9 +863,13 @@ export async function executeMulti(
export interface ExecuteMultiProgress {
executionId: string;
statementIndex: number;
completed: number;
total: number;
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
}
export async function executeMultiWithProgress(
@ -885,15 +889,26 @@ export async function executeMultiWithProgress(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
executionId?: string;
},
): Promise<QueryResult[]> {
const executionId = crypto.randomUUID();
const results = await executeMulti(connectionId, database, sql, schema, executionId, options);
onProgress({
executionId,
completed: results.length,
total: results.length,
success: !results.some((result) => result.execution_error === true),
const executionId = options?.executionId ?? crypto.randomUUID();
const { executionId: _executionId, ...executeOptions } = options ?? {};
const results = await executeMulti(connectionId, database, sql, schema, executionId, executeOptions);
const total = results.length;
results.forEach((result, index) => {
const statementIndex = result.statement_index ?? index;
const success = result.execution_error !== true;
onProgress({
executionId,
statementIndex,
completed: index + 1,
total,
success,
executionTimeMs: result.execution_time_ms,
affectedRows: result.affected_rows,
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
});
});
return results;
}

View File

@ -1071,9 +1071,13 @@ export async function executeMulti(
export interface ExecuteMultiProgress {
executionId: string;
statementIndex: number;
completed: number;
total: number;
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
}
export async function executeMultiWithProgress(
@ -1093,21 +1097,16 @@ export async function executeMultiWithProgress(
useTransaction?: boolean;
continueOnError?: boolean;
executionMode?: "simple";
executionId?: string;
},
): Promise<QueryResult[]> {
const executionId = crypto.randomUUID();
const executionId = options?.executionId ?? crypto.randomUUID();
const { executionId: _executionId, ...invokeOptions } = options ?? {};
const unlisten = await listen<ExecuteMultiProgress>("query-batch-progress", (event) => {
if (event.payload.executionId === executionId) onProgress(event.payload);
});
try {
return await invoke("execute_multi", {
connectionId,
database,
sql,
schema,
executionId,
...options,
});
return await invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...invokeOptions });
} finally {
unlisten();
}

View File

@ -30,9 +30,16 @@ function createStatusIconDom(status: StatementExecutionMarkerStatus, includeCirc
svg.appendChild(circle);
}
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
mark.setAttribute("d", status === "error" ? "m16 8-8 8m0-8 8 8" : "m7 12 3 3 7-7");
svg.appendChild(mark);
if (status === "running") {
svg.classList.add("cm-statement-execution-spinner");
const arc = document.createElementNS("http://www.w3.org/2000/svg", "path");
arc.setAttribute("d", "M21 12a9 9 0 1 1-6.22-8.56");
svg.appendChild(arc);
} else {
const mark = document.createElementNS("http://www.w3.org/2000/svg", "path");
mark.setAttribute("d", status === "error" ? "m16 8-8 8m0-8 8 8" : "m7 12 3 3 7-7");
svg.appendChild(mark);
}
return svg;
}

View File

@ -4,7 +4,7 @@ import { findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { executableStatementRanges, splitSqlStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, QueryResult, QueryTab } from "@/types/database";
type Translate = (key: string, params?: Record<string, unknown>) => string;
export type OutputView = "result" | "summary" | "explain" | "chart";
@ -203,13 +203,14 @@ export function resultSourceRange(editorSql: string, result: Pick<QueryResult, "
return { from: match.from, to: match.to, sql: match.sql };
}
export type StatementExecutionMarkerStatus = "success" | "error";
export type StatementExecutionMarkerStatus = "running" | "success" | "error";
export interface StatementExecutionMarker {
from: number;
status: StatementExecutionMarkerStatus;
successCount: number;
errorCount: number;
runningCount?: number;
}
function lineStartOffset(sql: string, from: number): number {
@ -222,7 +223,30 @@ function statementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange
return splitSqlStatementRanges(sql, databaseType);
}
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql)): StatementExecutionMarker[] {
function liveStatementExecutionMarkers(editorSql: string, batch: BatchSqlExecution): StatementExecutionMarker[] {
if (sqlTextFingerprint(editorSql) !== batch.editorFingerprint || batch.total === 0) return [];
const byLine = new Map<number, { success: number; error: number; running: number }>();
for (const item of batch.items) {
if (item.status !== "running" && item.status !== "success" && item.status !== "error") continue;
if (editorSql.slice(item.from, item.to) !== item.sql) continue;
const from = lineStartOffset(editorSql, item.from);
const current = byLine.get(from) ?? { success: 0, error: 0, running: 0 };
current[item.status] += 1;
byLine.set(from, current);
}
return [...byLine.entries()]
.sort(([a], [b]) => a - b)
.map(([from, counts]) => ({
from,
status: counts.error > 0 ? "error" : counts.running > 0 ? "running" : "success",
successCount: counts.success,
errorCount: counts.error,
...(counts.running > 0 ? { runningCount: counts.running } : {}),
}));
}
export function statementExecutionMarkers(editorSql: string, results: QueryResult[] | undefined, databaseType?: DatabaseType, submittedSql = editorSql, executionEditorFingerprint = sqlTextFingerprint(editorSql), batch?: BatchSqlExecution): StatementExecutionMarker[] {
if (batch?.items.length) return liveStatementExecutionMarkers(editorSql, batch);
if (!results?.length || sqlTextFingerprint(editorSql) !== executionEditorFingerprint) return [];
const submittedStatements = statementRanges(submittedSql, databaseType);
if (submittedStatements.length <= 1) return [];
@ -310,8 +334,14 @@ export function nextExecutionSummaryView(currentView: OutputView, canShowResult:
}
export interface ExecutionSummaryItem {
result: QueryResult;
result?: QueryResult;
index: number;
statementIndex: number;
sql?: string;
sourceFrom?: number;
sourceTo?: number;
status: "pending" | "running" | "success" | "error" | "skipped" | "cancelled";
error?: string;
returnedColumns: number;
returnedRows: number;
affectedRows: number;
@ -320,18 +350,48 @@ export interface ExecutionSummaryItem {
isError: boolean;
}
export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results">): ExecutionSummaryItem[] {
export function executionSummaryItems(tab: Pick<QueryTab, "result" | "results" | "batchSqlExecution">): ExecutionSummaryItem[] {
const results = tab.results?.length ? tab.results : tab.result ? [tab.result] : [];
return results.map((result, index) => ({
result,
index,
returnedColumns: result.columns.length,
returnedRows: result.rows.length,
affectedRows: result.affected_rows,
executionTimeMs: result.execution_time_ms,
hasTabularResult: result.columns.length > 0,
isError: result.columns.includes("Error"),
}));
if (tab.batchSqlExecution?.items.length) {
return tab.batchSqlExecution.items.map((item, index) => {
const result = results.find((candidate, resultIndex) => (candidate.statement_index ?? resultIndex) === item.statementIndex);
return {
result,
index,
statementIndex: item.statementIndex,
sql: item.sql,
sourceFrom: item.from,
sourceTo: item.to,
status: item.status,
error: item.error,
returnedColumns: result?.columns.length ?? 0,
returnedRows: result?.rows.length ?? 0,
affectedRows: item.affectedRows ?? result?.affected_rows ?? 0,
executionTimeMs: item.executionTimeMs ?? result?.execution_time_ms ?? 0,
hasTabularResult: (result?.columns.length ?? 0) > 0,
isError: item.status === "error",
};
});
}
return results.map((result, index) => {
const isError = result.execution_error === true || result.columns.includes("Error");
return {
result,
index,
statementIndex: result.statement_index ?? index,
sql: result.sourceStatement,
sourceFrom: result.sourceFrom,
sourceTo: result.sourceTo,
status: isError ? "error" : "success",
error: isError ? String(result.rows[0]?.[0] ?? "") : undefined,
returnedColumns: result.columns.length,
returnedRows: result.rows.length,
affectedRows: result.affected_rows,
executionTimeMs: result.execution_time_ms,
hasTabularResult: result.columns.length > 0,
isError,
};
});
}
export function tabModeLabel(tab: QueryTab, t: Translate): string {

View File

@ -58,6 +58,19 @@ describe("queryStore cancel timeout recovery", () => {
const store = useQueryStore();
const tabId = store.createTab("duckdb-1", "main", "query_1");
store.setExecutingWithId(tabId, "exec-1");
store.tabs[0]!.batchSqlExecution = {
executionId: "exec-1",
submittedSql: "SELECT 1; SELECT 2",
editorFingerprint: "fingerprint",
sourceOffset: 0,
completed: 0,
total: 2,
startedAt: Date.now(),
items: [
{ statementIndex: 0, sql: "SELECT 1", from: 0, to: 8, status: "running" },
{ statementIndex: 1, sql: "SELECT 2", from: 10, to: 18, status: "pending" },
],
};
const cancel = store.cancelTabExecution(tabId);
await vi.advanceTimersByTimeAsync(1);
@ -71,6 +84,10 @@ describe("queryStore cancel timeout recovery", () => {
isExecuting: false,
isCancelling: false,
executionId: undefined,
batchSqlExecution: {
finishedAt: expect.any(Number),
items: [{ status: "cancelled" }, { status: "skipped" }],
},
});
expect(store.tabs[0]?.result?.rows[0]?.[0]).toContain("Query canceled");
}, 15_000);

View File

@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
closeQuerySession: vi.fn(),
ensureConnected: vi.fn(),
executeMulti: vi.fn(),
executeMultiWithProgress: vi.fn(),
executeQuery: vi.fn(),
getConnectionConfig: vi.fn(),
prepareQueryPaginationExecutionPlan: vi.fn(),
@ -21,6 +22,7 @@ vi.mock("@/lib/backend/api", () => ({
closeClientConnectionSession: mocks.closeClientConnectionSession,
closeQuerySession: mocks.closeQuerySession,
executeMulti: mocks.executeMulti,
executeMultiWithProgress: mocks.executeMultiWithProgress,
executeQuery: mocks.executeQuery,
prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan,
saveOpenTabsState: mocks.saveOpenTabsState,
@ -99,6 +101,25 @@ describe("queryStore multi-statement errors", () => {
useAgentResultSession: false,
}));
mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "multiple-statements" });
mocks.executeMultiWithProgress.mockImplementation(async (connectionId, database, sql, onProgress, schema, options) => {
const results = await mocks.executeMulti(connectionId, database, sql, schema, options?.executionId, options);
const total = results.length;
results.forEach((result: any, index: number) => {
const statementIndex = result.statement_index ?? index;
const success = result.execution_error !== true;
onProgress({
executionId: options?.executionId,
statementIndex,
completed: index + 1,
total,
success,
executionTimeMs: result.execution_time_ms,
affectedRows: result.affected_rows,
error: success ? undefined : String(result.rows[0]?.[0] ?? ""),
});
});
return results;
});
});
it("opens the first error result from a mixed result batch", async () => {
@ -117,6 +138,126 @@ describe("queryStore multi-statement errors", () => {
expect(tab.result?.columns).toEqual(["Error"]);
});
it("updates live per-statement progress before the batch promise resolves", async () => {
const pendingExecution = deferred<any[]>();
let reportProgress!: (progress: any) => void;
mocks.executeMultiWithProgress.mockImplementationOnce((_connectionId, _database, _sql, onProgress) => {
reportProgress = onProgress;
return pendingExecution.promise;
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, "SELECT 1;\nSELECT 2;\nSELECT bad");
const execution = store.executeTabSql(tabId, "SELECT 1;\nSELECT 2;\nSELECT bad");
await vi.waitFor(() => expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items[0]?.status).toBe("running"));
const executionId = store.tabs.find((item) => item.id === tabId)!.executionId!;
reportProgress({
executionId,
statementIndex: 0,
completed: 1,
total: 3,
success: true,
executionTimeMs: 4,
affectedRows: 1,
});
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
completed: 1,
total: 3,
items: [{ status: "success", executionTimeMs: 4, affectedRows: 1 }, { status: "running" }, { status: "pending" }],
});
pendingExecution.resolve([
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 4, statement_index: 0 },
{ columns: ["Error"], rows: [["bad statement"]], affected_rows: 0, execution_time_ms: 2, statement_index: 1, execution_error: true },
]);
await execution;
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
completed: 2,
items: [{ status: "success" }, { status: "error", error: "bad statement" }, { status: "skipped" }],
});
});
it("records a top-level batch failure on the current statement", async () => {
mocks.executeMultiWithProgress.mockRejectedValueOnce(new Error("transport failed"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, "SELECT 1;\nSELECT 2");
await store.executeTabSql(tabId, "SELECT 1;\nSELECT 2");
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
completed: 1,
items: [{ status: "error", error: "transport failed" }, { status: "skipped" }],
});
});
it("keeps completed progress and records a later top-level batch failure", async () => {
mocks.executeMultiWithProgress.mockImplementationOnce((_connectionId, _database, _sql, onProgress, _schema, options) => {
onProgress({
executionId: options?.executionId,
statementIndex: 0,
completed: 1,
total: 3,
success: true,
executionTimeMs: 4,
affectedRows: 1,
});
return Promise.reject(new Error("connection lost"));
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, "SELECT 1;\nSELECT 2;\nSELECT 3");
await store.executeTabSql(tabId, "SELECT 1;\nSELECT 2;\nSELECT 3");
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
completed: 2,
items: [{ status: "success" }, { status: "error", error: "connection lost" }, { status: "skipped" }],
});
});
it("updates the live marker state for a single statement", async () => {
const pendingExecution = deferred<any[]>();
mocks.executeMulti.mockImplementationOnce(() => pendingExecution.promise);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const sql = "SELECT 1";
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, sql);
const execution = store.executeTabSql(tabId, sql);
await vi.waitFor(() => expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items[0]?.status).toBe("running"));
expect(mocks.executeMultiWithProgress).not.toHaveBeenCalled();
pendingExecution.resolve([{ columns: ["Error"], rows: [["bad statement"]], affected_rows: 0, execution_time_ms: 3, statement_index: 0, execution_error: true }]);
await execution;
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution).toMatchObject({
completed: 1,
total: 1,
items: [{ status: "error", executionTimeMs: 3, affectedRows: 0, error: "bad statement" }],
});
});
it("marks the active statement cancelled and leaves later statements unexecuted", async () => {
const pendingExecution = deferred<never>();
mocks.executeMultiWithProgress.mockImplementationOnce(() => pendingExecution.promise);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("mysql-1", "app", "Query", "query", undefined, "SELECT 1;\nSELECT 2");
const execution = store.executeTabSql(tabId, "SELECT 1;\nSELECT 2");
await vi.waitFor(() => expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items[0]?.status).toBe("running"));
await expect(store.cancelTabExecution(tabId)).resolves.toBe(true);
pendingExecution.reject(new Error("Query canceled"));
await execution;
expect(store.tabs.find((item) => item.id === tabId)?.batchSqlExecution?.items).toMatchObject([{ status: "cancelled" }, { status: "skipped" }]);
});
it("opens a later PostgreSQL error result from a mixed result batch", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "postgres-1",
@ -457,19 +598,37 @@ describe("queryStore multi-statement errors", () => {
const tab = store.tabs.find((item) => item.id === tabId)!;
const retainedRunId = tab.activeResultRunId!;
const retainedRunCacheKey = tab.resultRuns?.find((run) => run.id === retainedRunId)?.resultCacheKey;
expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 1 AS value");
const execution = store.executeCurrentSql("SELECT 2 AS value", { openInNewResultTab: true });
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
expect(await store.setActiveResultRun(tabId, retainedRunId)).toBe(true);
expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 1 AS value");
pendingExecution.resolve([{ columns: ["value"], rows: [[2]], affected_rows: 0, execution_time_ms: 1 }]);
await execution;
expect(tab.resultRuns).toHaveLength(2);
expect(tab.activeResultRunId).not.toBe(retainedRunId);
expect(tab.result).toMatchObject({ rows: [[2]] });
expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 2 AS value");
expect(tab.resultRuns?.find((run) => run.id === tab.activeResultRunId)?.resultCacheKey).not.toBe(retainedRunCacheKey);
expect(await store.setActiveResultRun(tabId, retainedRunId)).toBe(true);
expect(tab.result).toMatchObject({ rows: [[1]] });
expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 1 AS value");
const newRunId = tab.resultRuns?.find((run) => run.id !== retainedRunId)?.id;
expect(newRunId).toBeTruthy();
expect(await store.setActiveResultRun(tabId, newRunId!)).toBe(true);
await vi.waitFor(() => expect(mocks.tabResultSnapshots.has(retainedRunCacheKey!)).toBe(true));
const retainedRun = tab.resultRuns?.find((run) => run.id === retainedRunId);
expect(retainedRun).toBeTruthy();
retainedRun!.result = undefined;
retainedRun!.results = undefined;
retainedRun!.batchSqlExecution = undefined;
expect(await store.setActiveResultRun(tabId, retainedRunId)).toBe(true);
expect(tab.result).toMatchObject({ rows: [[1]] });
expect(tab.batchSqlExecution?.submittedSql).toBe("SELECT 1 AS value");
});
it("restores the retained result when a new-result execution returns no result", async () => {

View File

@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, onScopeDispose, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, ObjectBrowserViewport, QueryResult, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult } from "@/lib/diagram/explainPlan";
@ -209,6 +209,126 @@ function annotateQueryResultSources(results: QueryResult[], sql: string, databas
return results;
}
const NON_STREAMING_BATCH_DATABASE_TYPES = new Set<DatabaseType>(["sqlserver", "turso", "cloudflare-d1"]);
const liveBatchSqlExecutions = new WeakMap<QueryTab, BatchSqlExecution>();
function cloneBatchSqlExecution(batch: BatchSqlExecution | undefined): BatchSqlExecution | undefined {
return batch ? { ...batch, items: batch.items.map((item) => ({ ...item })) } : undefined;
}
function batchSqlExecutionFor(tab: QueryTab, executionId: string): BatchSqlExecution | undefined {
const liveBatch = liveBatchSqlExecutions.get(tab);
if (liveBatch?.executionId === executionId) return liveBatch;
return tab.batchSqlExecution?.executionId === executionId ? tab.batchSqlExecution : undefined;
}
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 {
const statements = splitSqlStatementRanges(submittedSql, databaseType);
if (statements.length === 0) return undefined;
if (statements.length > 1 && databaseType && NON_STREAMING_BATCH_DATABASE_TYPES.has(databaseType)) return undefined;
const offset = sourceOffset ?? 0;
return {
executionId,
submittedSql,
editorFingerprint: sqlTextFingerprint(editorSql),
sourceOffset: offset,
completed: 0,
total: statements.length,
startedAt: Date.now(),
items: statements.map((statement, statementIndex) => ({
statementIndex,
sql: statement.sql,
from: offset + statement.from,
to: offset + statement.to,
status: statementIndex === 0 ? "running" : "pending",
})),
};
}
function applyBatchSqlProgress(
tab: QueryTab,
progress: {
executionId: string;
statementIndex: number;
completed: number;
total: number;
success: boolean;
executionTimeMs: number;
affectedRows: number;
error?: string;
},
continueOnError: boolean,
) {
const batch = batchSqlExecutionFor(tab, progress.executionId);
if (!batch) return;
const item = batch.items[progress.statementIndex];
if (!item) return;
item.status = progress.success ? "success" : "error";
item.executionTimeMs = progress.executionTimeMs;
item.affectedRows = progress.affectedRows;
item.error = progress.error;
batch.completed = Math.max(batch.completed, progress.completed);
if ((progress.success || continueOnError) && progress.completed < batch.total) {
const next = batch.items[progress.statementIndex + 1];
if (next?.status === "pending") next.status = "running";
}
}
function reconcileBatchSqlResults(tab: QueryTab, executionId: string, results: QueryResult[]) {
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
let fallbackIndex = 0;
for (const result of results) {
const statementIndex = Number.isInteger(result.statement_index) && result.statement_index! >= 0 ? result.statement_index! : fallbackIndex;
fallbackIndex = Math.max(fallbackIndex, statementIndex + 1);
const item = batch.items[statementIndex];
if (!item) continue;
const failed = result.execution_error === true;
item.status = failed ? "error" : "success";
item.executionTimeMs = result.execution_time_ms;
item.affectedRows = result.affected_rows;
item.error = failed ? String(result.rows[0]?.[0] ?? "") : undefined;
}
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
}
function failBatchSqlExecution(tab: QueryTab, executionId: string, error: unknown, cancelled: boolean) {
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
const item = batch.items.find((candidate) => candidate.status === "running") ?? batch.items.find((candidate) => candidate.status === "pending");
if (!item) return;
item.status = cancelled ? "cancelled" : "error";
item.error = cancelled ? undefined : error instanceof Error ? error.message : String(error);
batch.completed = batch.items.filter((candidate) => candidate.status === "success" || candidate.status === "error").length;
}
function finishBatchSqlExecution(tab: QueryTab, executionId: string, cancelled: boolean) {
const batch = batchSqlExecutionFor(tab, executionId);
if (!batch) return;
if (cancelled) {
const cancelledError = [...batch.items].reverse().find((item) => item.status === "error" && /cancel|取消/i.test(item.error ?? ""));
if (cancelledError) {
cancelledError.status = "cancelled";
cancelledError.error = undefined;
}
}
let markedCancelled = false;
for (const item of batch.items) {
if (item.status === "running" && cancelled && !markedCancelled) {
item.status = "cancelled";
markedCancelled = true;
} else if (item.status === "running" || item.status === "pending") {
item.status = "skipped";
}
}
batch.completed = batch.items.filter((item) => item.status === "success" || item.status === "error").length;
batch.finishedAt = Date.now();
}
function sqlStatementWithoutLeadingComments(statement: string | undefined): string {
let remaining = statement?.trimStart() ?? "";
while (remaining) {
@ -630,6 +750,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = undefined;
tab.results = undefined;
tab.activeResultIndex = undefined;
tab.batchSqlExecution = undefined;
tab.resultEditorFingerprint = undefined;
tab.resultLocalSortOriginalRows = undefined;
tab.resultLocalSortOriginalMongoDocuments = undefined;
@ -699,6 +820,7 @@ export const useQueryStore = defineStore("query", () => {
tab.result = run.result ?? run.results?.[activeIndex];
tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex;
tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution);
tab.resultBaseSql = run.resultBaseSql;
tab.resultEditorFingerprint = run.resultEditorFingerprint;
tab.resultSortedSql = run.resultSortedSql;
@ -863,6 +985,7 @@ export const useQueryStore = defineStore("query", () => {
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
@ -922,6 +1045,7 @@ export const useQueryStore = defineStore("query", () => {
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint,
resultSortedSql: tab.resultSortedSql,
@ -2568,6 +2692,7 @@ export const useQueryStore = defineStore("query", () => {
pendingResultRunRestores.delete(executionId);
return;
}
finishBatchSqlExecution(current, executionId, true);
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
@ -2579,6 +2704,7 @@ export const useQueryStore = defineStore("query", () => {
current.resultSessionId = undefined;
touchResult(current);
}
clearLiveBatchSqlExecution(current, executionId);
}, CANCEL_ACK_SETTLE_TIMEOUT_MS);
}
@ -3129,6 +3255,8 @@ export const useQueryStore = defineStore("query", () => {
if (openInNewResultTab && 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 && !openInNewResultTab);
const updateActiveResultRun = !!tab.activeResultRunId && preserveResultDuringExecution;
if (!updateActiveResultRun) {
@ -3187,6 +3315,9 @@ export const useQueryStore = defineStore("query", () => {
const useAgentCursor = usesAgentCursorForQuery(conn?.db_type);
const queryTimeoutSecs = queryTimeoutSecsForConnection(conn);
const settingsStore = useSettingsStore();
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);
queryExecutionLog("info", "previous-session-close:start", { traceId, elapsed: elapsed() });
await previousResultSessionClose;
queryExecutionLog("info", "previous-session-close:done", { traceId, elapsed: elapsed() });
@ -3740,9 +3871,25 @@ export const useQueryStore = defineStore("query", () => {
clientSession: Boolean(clientSessionId),
});
executionDispatched = true;
executionPromise = api.executeMulti(tab.connectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
executionPromise =
tab.batchSqlExecution && tab.batchSqlExecution.total > 1
? api.executeMultiWithProgress(
tab.connectionId,
executionDatabase,
sqlToExecute,
(progress) => {
const current = tabs.value.find((item) => item.id === id);
if (current?.executionId === executionId) {
applyBatchSqlProgress(current, progress, settingsStore.editorSettings.continueOnErrorOnBatch);
}
},
executionSchema,
{ ...executionOptions, executionId },
)
: api.executeMulti(tab.connectionId, executionDatabase, sqlToExecute, executionSchema, executionId, executionOptions);
}
const results = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset);
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;
if (hiddenPrimaryKeys.length > 0 && results.length === 1) {
@ -3916,6 +4063,7 @@ export const useQueryStore = defineStore("query", () => {
}
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
failBatchSqlExecution(current, executionId, e, current.isCancelling === true);
const restoredRetainedResult = openInNewResultTab && (current.isCancelling || !executionDispatched) && restorePendingResultRun(current, executionId);
if (restoredRetainedResult) {
queryExecutionLog("info", "retained-result:restored-after-abort", { traceId, elapsed: elapsed() });
@ -3960,6 +4108,10 @@ export const useQueryStore = defineStore("query", () => {
} finally {
const current = tabs.value.find((t) => t.id === id);
if (current?.executionId === executionId) {
const liveBatch = liveBatchSqlExecutions.get(current);
if (liveBatch?.executionId === executionId) current.batchSqlExecution = liveBatch;
finishBatchSqlExecution(current, executionId, current.isCancelling === true);
if (current.activeResultRunId && current.result) syncActiveResultRunFromDisplayed(current);
if (openInNewResultTab && !current.activeResultRunId) {
restorePendingResultRun(current, executionId);
} else {
@ -3969,9 +4121,11 @@ export const useQueryStore = defineStore("query", () => {
current.isCancelling = false;
current.queryExecutionStartedAt = undefined;
current.executionId = undefined;
clearLiveBatchSqlExecution(current, executionId);
queryExecutionLog("info", "finish", { traceId, elapsed: elapsed() });
} else {
pendingResultRunRestores.delete(executionId);
if (current) clearLiveBatchSqlExecution(current, executionId);
queryExecutionLog("warn", "finish-stale", {
traceId,
currentExecutionId: current?.executionId,
@ -4331,11 +4485,13 @@ export const useQueryStore = defineStore("query", () => {
if (!canceled) {
const current = tabs.value.find((t) => t.id === id);
if (current && current.executionId === executionId) {
finishBatchSqlExecution(current, executionId, false);
restorePendingResultRun(current, executionId);
current.isExecuting = false;
current.isCancelling = false;
current.executionId = undefined;
current.queryExecutionStartedAt = undefined;
clearLiveBatchSqlExecution(current, executionId);
}
}
return canceled;
@ -4344,6 +4500,8 @@ export const useQueryStore = defineStore("query", () => {
if (tab) useConnectionStore().recordConnectionLostError(tab.connectionId, e);
const current = tabs.value.find((t) => t.id === id);
if (current && current.executionId === executionId) {
failBatchSqlExecution(current, executionId, e, false);
finishBatchSqlExecution(current, executionId, false);
if (restorePendingResultRun(current, executionId)) {
current.isExecuting = false;
current.isCancelling = false;
@ -4354,6 +4512,7 @@ export const useQueryStore = defineStore("query", () => {
// 估算值也会继续按旧的 results 计算
setErrorResult(id, e);
}
clearLiveBatchSqlExecution(current, executionId);
}
return false;
} finally {

View File

@ -601,6 +601,31 @@ export interface QueryResult {
sourceTo?: number;
}
export type BatchStatementExecutionStatus = "pending" | "running" | "success" | "error" | "skipped" | "cancelled";
export interface BatchStatementExecutionItem {
statementIndex: number;
sql: string;
from: number;
to: number;
status: BatchStatementExecutionStatus;
executionTimeMs?: number;
affectedRows?: number;
error?: string;
}
export interface BatchSqlExecution {
executionId: string;
submittedSql: string;
editorFingerprint: string;
sourceOffset: number;
completed: number;
total: number;
startedAt: number;
finishedAt?: number;
items: BatchStatementExecutionItem[];
}
export interface QueryResultRun {
id: string;
title: string;
@ -610,6 +635,7 @@ export interface QueryResultRun {
result?: QueryResult;
results?: QueryResult[];
activeResultIndex?: number;
batchSqlExecution?: BatchSqlExecution;
resultBaseSql?: string;
/** Fingerprint of the complete editor document when this result run started. */
resultEditorFingerprint?: string;
@ -917,6 +943,8 @@ export interface QueryTab {
isExecuting: boolean;
isCancelling?: boolean;
queryExecutionStartedAt?: number;
/** Ephemeral per-statement progress for the latest multi-statement execution. */
batchSqlExecution?: BatchSqlExecution;
editorViewport?: {
scrollTop: number;
scrollLeft: number;

View File

@ -80,7 +80,39 @@ pub struct ExecuteMultiResult {
pub statement_index: Option<usize>,
}
pub type ExecuteMultiProgressCallback = Arc<dyn Fn(usize, usize, bool) + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecuteMultiProgress {
pub statement_index: usize,
pub completed: usize,
pub total: usize,
pub success: bool,
pub execution_time_ms: u128,
pub affected_rows: u64,
pub error: Option<String>,
}
pub type ExecuteMultiProgressCallback = Arc<dyn Fn(ExecuteMultiProgress) + Send + Sync>;
fn report_execute_multi_progress(
progress: Option<&ExecuteMultiProgressCallback>,
statement_index: usize,
total: usize,
result: &db::QueryResult,
success: bool,
error: Option<String>,
) {
if let Some(progress) = progress {
progress(ExecuteMultiProgress {
statement_index,
completed: statement_index + 1,
total,
success,
execution_time_ms: result.execution_time_ms,
affected_rows: result.affected_rows,
error,
});
}
}
impl ExecuteMultiResult {
fn execution_error(result: db::QueryResult) -> Self {
@ -2213,17 +2245,21 @@ pub async fn execute_multi_core_with_options_for_client_and_progress(
.await
{
Ok(r) => {
report_execute_multi_progress(progress.as_ref(), statement_index, statements.len(), &r, true, None);
results.push(ExecuteMultiResult::success_with_index(r, statement_index));
if let Some(progress) = progress.as_ref() {
progress(statement_index + 1, statements.len(), true);
}
}
Err(e) => {
let action = query_pool_error_action(db_type, stmt, &e);
results.push(ExecuteMultiResult::execution_error_with_index(error_query_result(e), statement_index));
if let Some(progress) = progress.as_ref() {
progress(statement_index + 1, statements.len(), false);
}
let result = error_query_result(e.clone());
report_execute_multi_progress(
progress.as_ref(),
statement_index,
statements.len(),
&result,
false,
Some(e),
);
results.push(ExecuteMultiResult::execution_error_with_index(result, statement_index));
if !should_continue_batch_after_error(options.continue_on_error, action) {
break;
}
@ -2284,17 +2320,14 @@ where
match executor.execute_statement(statement).await {
Ok(result) => {
report_execute_multi_progress(progress, statement_index, statements.len(), &result, true, None);
results.push(ExecuteMultiResult::success_with_index(result, statement_index));
if let Some(progress) = progress {
progress(statement_index + 1, statements.len(), true);
}
}
Err(err) => {
let action = pool_error_action(db_type, &err);
results.push(ExecuteMultiResult::execution_error_with_index(error_query_result(err), statement_index));
if let Some(progress) = progress {
progress(statement_index + 1, statements.len(), false);
}
let result = error_query_result(err.clone());
report_execute_multi_progress(progress, statement_index, statements.len(), &result, false, Some(err));
results.push(ExecuteMultiResult::execution_error_with_index(result, statement_index));
// 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) {
@ -4324,7 +4357,7 @@ mod tests {
let progress_events = Arc::new(std::sync::Mutex::new(Vec::new()));
let progress: ExecuteMultiProgressCallback = {
let progress_events = Arc::clone(&progress_events);
Arc::new(move |completed, total, success| progress_events.lock().unwrap().push((completed, total, success)))
Arc::new(move |event| progress_events.lock().unwrap().push(event))
};
let (results, error_action) = execute_mysql_batch_statements(
@ -4339,7 +4372,29 @@ mod tests {
assert_eq!(executor.executed, vec!["first", "fails"]);
assert_eq!(results.len(), 2);
assert_eq!(*progress_events.lock().unwrap(), vec![(1, 3, true), (2, 3, false)]);
assert_eq!(
*progress_events.lock().unwrap(),
vec![
ExecuteMultiProgress {
statement_index: 0,
completed: 1,
total: 3,
success: true,
execution_time_ms: 0,
affected_rows: 0,
error: None,
},
ExecuteMultiProgress {
statement_index: 1,
completed: 2,
total: 3,
success: false,
execution_time_ms: 0,
affected_rows: 0,
error: Some("Duplicate entry".to_string()),
},
]
);
assert_eq!(error_action, Some(PoolErrorAction::Keep));
}

View File

@ -339,6 +339,36 @@ test("execution summary items include table and non-table statement results", ()
);
});
test("execution summary items preserve live statuses and unexecuted statements", () => {
const sql = "INSERT 1;\nINSERT 2;\nINSERT 3";
const items = executionSummaryItems({
batchSqlExecution: {
executionId: "run-1",
submittedSql: sql,
editorFingerprint: "fingerprint",
sourceOffset: 0,
completed: 2,
total: 3,
startedAt: 1,
finishedAt: 2,
items: [
{ statementIndex: 0, sql: "INSERT 1", from: 0, to: 8, status: "success", affectedRows: 1, executionTimeMs: 3 },
{ statementIndex: 1, sql: "INSERT 2", from: 10, to: 18, status: "error", error: "duplicate", executionTimeMs: 2 },
{ statementIndex: 2, sql: "INSERT 3", from: 20, to: 28, status: "skipped" },
],
},
});
assert.deepEqual(
items.map(({ statementIndex, status, affectedRows, error }) => ({ statementIndex, status, affectedRows, error })),
[
{ statementIndex: 0, status: "success", affectedRows: 1, error: undefined },
{ statementIndex: 1, status: "error", affectedRows: 0, error: "duplicate" },
{ statementIndex: 2, status: "skipped", affectedRows: 0, error: undefined },
],
);
});
test("execution summary button toggles back to result only when result view is available", () => {
assert.equal(nextExecutionSummaryView("result", true), "summary");
assert.equal(nextExecutionSummaryView("chart", true), "summary");

View File

@ -12,9 +12,13 @@ use dbx_core::sql::split_sql_statements;
#[serde(rename_all = "camelCase")]
struct ExecuteMultiProgress {
execution_id: String,
statement_index: usize,
completed: usize,
total: usize,
success: bool,
execution_time_ms: u128,
affected_rows: u64,
error: Option<String>,
}
#[tauri::command]
@ -99,10 +103,19 @@ pub async fn execute_multi(
let progress = execution_id.as_ref().map(|execution_id| {
let app = app.clone();
let execution_id = execution_id.clone();
Arc::new(move |completed, total, success| {
Arc::new(move |progress: dbx_core::query::ExecuteMultiProgress| {
let _ = app.emit(
"query-batch-progress",
ExecuteMultiProgress { execution_id: execution_id.clone(), completed, total, success },
ExecuteMultiProgress {
execution_id: execution_id.clone(),
statement_index: progress.statement_index,
completed: progress.completed,
total: progress.total,
success: progress.success,
execution_time_ms: progress.execution_time_ms,
affected_rows: progress.affected_rows,
error: progress.error,
},
);
}) as dbx_core::query::ExecuteMultiProgressCallback
});