diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index e3321e6e5..3f420e265 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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): 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; diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 2a0134114..4e09cb0f2 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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
-
- - {{ t("executionSummary.executing") }} +
+ + +
-
- {{ t("executionSummary.empty") }} -
-
+
+
+
+ {{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }} + {{ batchExecutionProgress.completed }} / {{ batchExecutionProgress.total }} + {{ batchExecutionPercent }}% +
+
+
+
+
-
+
{{ t("executionSummary.statement") }}
-
{{ t("executionSummary.type") }}
-
{{ t("executionSummary.rows") }}
+
{{ t("executionSummary.sql") }}
+
{{ t("executionSummary.status") }}
{{ t("executionSummary.affected") }}
{{ t("executionSummary.time") }}
-
-
#{{ item.index + 1 }}
-
- - {{ item.isError ? t("executionSummary.error") : t("executionSummary.success") }} - - - {{ item.hasTabularResult ? t("executionSummary.returnedTable", { count: item.returnedColumns }) : t("executionSummary.noTable") }} +
+
{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.affectedRows.toLocaleString() }}
+
{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}
+
+
{{ t("executionSummary.navigationHint") }}
diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index b27bd9abd..9310c37ae 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -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("app"), sql }); + const activeConnection = ref(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("app"), sql }); diff --git a/apps/desktop/src/composables/useSqlExecution.ts b/apps/desktop/src/composables/useSqlExecution.ts index b31193119..7b7d12ce1 100644 --- a/apps/desktop/src/composables/useSqlExecution.ts +++ b/apps/desktop/src/composables/useSqlExecution.ts @@ -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"; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 14b8a6c22..27e31eb61 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 0d163e24e..5f93ad6d1 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 77deef419..1a28062da 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 73e357175..afba7d4fb 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -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: "グラフ", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index c51d22bf0..4755a1fa9 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -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: "차트", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index f3c1d3b91..151d57203 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 00c1b4914..d023ef52f 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "图表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0e30c1722..10680c4d9 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "圖表", diff --git a/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts b/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts index 7f12a1be8..92d616125 100644 --- a/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts +++ b/apps/desktop/src/lib/__tests__/codemirrorStatementGutter.spec.ts @@ -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"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts index ff1509af0..b6f5506d5 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExecution.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index 1e6375251..bfb43e0ab 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -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 = { @@ -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([]); }); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts index 0dbe1475c..84598a8c3 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.spec.ts @@ -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())); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 67e7756d4..2b5a2176a 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -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 { - 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; } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 607ba6da8..f1abbcb9b 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -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 { - const executionId = crypto.randomUUID(); + const executionId = options?.executionId ?? crypto.randomUUID(); + const { executionId: _executionId, ...invokeOptions } = options ?? {}; const unlisten = await listen("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(); } diff --git a/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts b/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts index 413afa6c2..3ea577e5a 100644 --- a/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts +++ b/apps/desktop/src/lib/editor/codemirrorStatementGutter.ts @@ -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; } diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index dbd635b51..7a1f3367e 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -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; export type OutputView = "result" | "summary" | "explain" | "chart"; @@ -203,13 +203,14 @@ export function resultSourceRange(editorSql: string, result: Pick(); + 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): ExecutionSummaryItem[] { +export function executionSummaryItems(tab: Pick): 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 { diff --git a/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts index 0abd6cab0..290beb334 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.cancel.spec.ts @@ -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); diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index b08141563..4ce41b9d9 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -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(); + 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(); + 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(); + 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 () => { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 245f81469..3d8891d88 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -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(["sqlserver", "turso", "cloudflare-d1"]); +const liveBatchSqlExecutions = new WeakMap(); + +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 { diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 4d56ef1c5..13e31e442 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -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; diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 6a55eabe6..78f30d729 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -80,7 +80,39 @@ pub struct ExecuteMultiResult { pub statement_index: Option, } -pub type ExecuteMultiProgressCallback = Arc; +#[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, +} + +pub type ExecuteMultiProgressCallback = Arc; + +fn report_execute_multi_progress( + progress: Option<&ExecuteMultiProgressCallback>, + statement_index: usize, + total: usize, + result: &db::QueryResult, + success: bool, + error: Option, +) { + 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)); } diff --git a/packages/app-tests/tabPresentation.test.ts b/packages/app-tests/tabPresentation.test.ts index 0ad59a100..1ece4682d 100644 --- a/packages/app-tests/tabPresentation.test.ts +++ b/packages/app-tests/tabPresentation.test.ts @@ -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"); diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 5df89ee83..c135d71b9 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -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, } #[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 });