feat(query): show live statement execution progress
This commit is contained in:
parent
1774126f76
commit
ed018dbc77
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "グラフ",
|
||||
|
|
|
|||
|
|
@ -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: "차트",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "图表",
|
||||
|
|
|
|||
|
|
@ -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: "圖表",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue