feat(desktop): show batch drop progress
This commit is contained in:
11 files changed
+453
-37
No files matched your search
@@ -93,6 +93,7 @@ import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
|||||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||||||
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
|
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
|
||||||
|
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
buildObjectBrowserRows,
|
buildObjectBrowserRows,
|
||||||
@@ -224,6 +225,8 @@ const procedureExecutionTarget = ref<ObjectBrowserRow | null>(null);
|
|||||||
const selectedTableIds = ref<Set<string>>(new Set());
|
const selectedTableIds = ref<Set<string>>(new Set());
|
||||||
const expandedPartitionParentIds = ref<Set<string>>(new Set());
|
const expandedPartitionParentIds = ref<Set<string>>(new Set());
|
||||||
const showBatchDropConfirm = ref(false);
|
const showBatchDropConfirm = ref(false);
|
||||||
|
const batchDropExecuting = ref(false);
|
||||||
|
const batchDropProgress = ref({ completed: 0, total: 0 });
|
||||||
const batchDropPreviewSql = ref("");
|
const batchDropPreviewSql = ref("");
|
||||||
const showBatchTruncateConfirm = ref(false);
|
const showBatchTruncateConfirm = ref(false);
|
||||||
const batchTruncatePreviewSql = ref("");
|
const batchTruncatePreviewSql = ref("");
|
||||||
@@ -544,6 +547,7 @@ const selectedTableCount = computed(() => selectedTableRows.value.length);
|
|||||||
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
|
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
|
||||||
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
|
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
|
||||||
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
|
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
|
||||||
|
const batchDropProgressPercent = computed(() => (batchDropProgress.value.total > 0 ? Math.round((batchDropProgress.value.completed / batchDropProgress.value.total) * 100) : 0));
|
||||||
|
|
||||||
function iconFor(row: ObjectBrowserRow) {
|
function iconFor(row: ObjectBrowserRow) {
|
||||||
if (row.type === "VIEW" || row.type === "MATERIALIZED_VIEW") return Eye;
|
if (row.type === "VIEW" || row.type === "MATERIALIZED_VIEW") return Eye;
|
||||||
@@ -1514,37 +1518,55 @@ async function refreshBatchDropPreviewSql() {
|
|||||||
function requestBatchDropTables() {
|
function requestBatchDropTables() {
|
||||||
if (selectedTableCount.value === 0) return;
|
if (selectedTableCount.value === 0) return;
|
||||||
batchDropCascade.value = false;
|
batchDropCascade.value = false;
|
||||||
|
batchDropProgress.value = { completed: 0, total: 0 };
|
||||||
batchDropPreviewSql.value = "";
|
batchDropPreviewSql.value = "";
|
||||||
void refreshBatchDropPreviewSql();
|
void refreshBatchDropPreviewSql();
|
||||||
showBatchDropConfirm.value = true;
|
showBatchDropConfirm.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmBatchDropTables() {
|
async function confirmBatchDropTables() {
|
||||||
const targets = await fetchSortedTableRowsForDrop();
|
if (batchDropExecuting.value) return;
|
||||||
if (targets.length === 0) return;
|
batchDropExecuting.value = true;
|
||||||
try {
|
try {
|
||||||
|
const targets = await fetchSortedTableRowsForDrop();
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
batchDropProgress.value = { completed: 0, total: targets.length };
|
||||||
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
|
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
|
||||||
const statements = await Promise.all(
|
const plan = await Promise.all(
|
||||||
targets.map(async (row) => ({
|
targets.map(async (target) => ({
|
||||||
row,
|
target,
|
||||||
sql: await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
|
sql: await buildDropTableSql(tableAdminSqlOptions(target, { cascade: useCascade })),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
|
const batchSql = plan.map(({ sql }) => sql).join(";\n");
|
||||||
for (const { row, sql } of statements) {
|
const result = await executeObjectBrowserSqlWithProductionGuard(batchSql, () =>
|
||||||
await api.executeQuery(props.connection.id, props.database, sql);
|
runBatchTableDrop({
|
||||||
closeDroppedTableObjectTabsForRow(row);
|
databaseType: effectiveDatabaseType.value,
|
||||||
}
|
plan,
|
||||||
return true;
|
executeStatement: (sql) => api.executeQuery(props.connection.id, props.database, sql),
|
||||||
});
|
executeBatch: (sql, onProgress) => api.executeMultiWithProgress(props.connection.id, props.database, sql, onProgress),
|
||||||
if (!executed) return;
|
onProgress: (progress) => {
|
||||||
toast(t("objects.batchDropSuccess", { count: targets.length }));
|
batchDropProgress.value = { completed: Math.min(progress.completed, targets.length), total: targets.length };
|
||||||
removePinnedObjectBrowserRows(targets);
|
},
|
||||||
clearTableSelection();
|
}),
|
||||||
await reload();
|
);
|
||||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
if (!result) return;
|
||||||
|
|
||||||
|
for (const row of result.succeeded) closeDroppedTableObjectTabsForRow(row);
|
||||||
|
if (result.succeeded.length > 0) {
|
||||||
|
removePinnedObjectBrowserRows(result.succeeded);
|
||||||
|
await reload();
|
||||||
|
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.failed) throw result.failed;
|
||||||
|
toast(t("objects.batchDropSuccess", { count: result.succeeded.length }));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||||
|
} finally {
|
||||||
|
batchDropExecuting.value = false;
|
||||||
|
batchDropProgress.value = { completed: 0, total: 0 };
|
||||||
|
showBatchDropConfirm.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3072,9 +3094,27 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
|||||||
</template>
|
</template>
|
||||||
</DangerConfirmDialog>
|
</DangerConfirmDialog>
|
||||||
|
|
||||||
<DangerConfirmDialog v-model:open="showBatchDropConfirm" :title="t('objects.confirmBatchDropTitle')" :message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })" :sql="batchDropPreviewSql" :confirm-label="t('objects.dropSelected')" @confirm="confirmBatchDropTables">
|
<DangerConfirmDialog
|
||||||
<template v-if="canBatchDropCascade" #options>
|
v-model:open="showBatchDropConfirm"
|
||||||
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
:title="t('objects.confirmBatchDropTitle')"
|
||||||
|
:message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })"
|
||||||
|
:sql="batchDropPreviewSql"
|
||||||
|
:confirm-label="t('objects.dropSelected')"
|
||||||
|
:loading="batchDropExecuting"
|
||||||
|
:close-on-confirm="false"
|
||||||
|
@confirm="confirmBatchDropTables"
|
||||||
|
>
|
||||||
|
<template #options>
|
||||||
|
<div v-if="batchDropExecuting" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
|
||||||
|
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
|
||||||
|
<span>{{ batchDropProgress.completed }} / {{ batchDropProgress.total }}</span>
|
||||||
|
<span>{{ batchDropProgressPercent }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="batchDropProgress.total" :aria-valuenow="batchDropProgress.completed">
|
||||||
|
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${batchDropProgressPercent}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label v-if="canBatchDropCascade" class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||||
<input v-model="batchDropCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshBatchDropPreviewSql()" />
|
<input v-model="batchDropCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshBatchDropPreviewSql()" />
|
||||||
<span class="grid gap-0.5">
|
<span class="grid gap-0.5">
|
||||||
<span class="font-medium text-foreground">{{ t("contextMenu.dropTableCascade") }}</span>
|
<span class="font-medium text-foreground">{{ t("contextMenu.dropTableCascade") }}</span>
|
||||||
|
|||||||
@@ -1879,8 +1879,17 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
|
|||||||
:close-on-confirm="false"
|
:close-on-confirm="false"
|
||||||
@confirm="confirmSidebarDangerDialog"
|
@confirm="confirmSidebarDangerDialog"
|
||||||
>
|
>
|
||||||
<template v-if="sidebarDangerDialogRequest.option" #options>
|
<template #options>
|
||||||
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
<div v-if="sidebarDangerDialogConfirming && sidebarDangerDialogRequest.progress" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
|
||||||
|
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
|
||||||
|
<span>{{ sidebarDangerDialogRequest.progress.completed }} / {{ sidebarDangerDialogRequest.progress.total }}</span>
|
||||||
|
<span>{{ Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="sidebarDangerDialogRequest.progress.total" :aria-valuenow="sidebarDangerDialogRequest.progress.completed">
|
||||||
|
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${Math.round((sidebarDangerDialogRequest.progress.completed / sidebarDangerDialogRequest.progress.total) * 100)}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label v-if="sidebarDangerDialogRequest.option" class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||||
<input :checked="sidebarDangerDialogRequest.option.checked" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="updateSidebarDangerDialogOption" />
|
<input :checked="sidebarDangerDialogRequest.option.checked" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="updateSidebarDangerDialogOption" />
|
||||||
<span class="grid gap-0.5">
|
<span class="grid gap-0.5">
|
||||||
<span class="font-medium text-foreground">{{ sidebarDangerDialogRequest.option.label }}</span>
|
<span class="font-medium text-foreground">{{ sidebarDangerDialogRequest.option.label }}</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, watch, onBeforeUnmount, inject, reactive, shallowRef } from "vue";
|
import { computed, nextTick, watch, onBeforeUnmount, inject, reactive, ref, shallowRef } from "vue";
|
||||||
import { createRoutedSidebarDialogController } from "./sidebarDialogControllerRouting";
|
import { createRoutedSidebarDialogController } from "./sidebarDialogControllerRouting";
|
||||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||||
import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime";
|
import { useSidebarDataOpenRuntime } from "@/composables/useSidebarDataOpenRuntime";
|
||||||
@@ -134,6 +134,7 @@ import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboar
|
|||||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||||
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
|
import { batchTableEmptyFeedback, runBatchTableEmpty } from "@/lib/sidebar/batchTableEmpty";
|
||||||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||||||
|
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
|
||||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||||
import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory";
|
import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory";
|
||||||
@@ -412,6 +413,8 @@ const { isTableNotView, supportsTruncate, canDropTableCascade, canTruncateTableC
|
|||||||
refreshMutatedTableDataTabsForNode,
|
refreshMutatedTableDataTabsForNode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const batchDropProgress = ref({ completed: 0, total: 0 });
|
||||||
|
|
||||||
const treeItemDialogOwner = Symbol("sidebar-tree-dialog-owner");
|
const treeItemDialogOwner = Symbol("sidebar-tree-dialog-owner");
|
||||||
|
|
||||||
function claimTreeItemDialogOwnership() {
|
function claimTreeItemDialogOwnership() {
|
||||||
@@ -1726,6 +1729,7 @@ function requestBatchDrop() {
|
|||||||
if (!targets.length) return;
|
if (!targets.length) return;
|
||||||
batchDropTargets.value = targets.slice();
|
batchDropTargets.value = targets.slice();
|
||||||
batchDropCascade.value = false;
|
batchDropCascade.value = false;
|
||||||
|
batchDropProgress.value = { completed: 0, total: 0 };
|
||||||
void refreshBatchDropPreviewSql();
|
void refreshBatchDropPreviewSql();
|
||||||
showBatchDropConfirm.value = true;
|
showBatchDropConfirm.value = true;
|
||||||
}
|
}
|
||||||
@@ -1979,6 +1983,46 @@ async function confirmBatchDrop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const useCascade = batchDropCascade.value && targets.every((node) => node.type !== "table" || supportsDropTableCascade(databaseTypeForNode(node)));
|
const useCascade = batchDropCascade.value && targets.every((node) => node.type !== "table" || supportsDropTableCascade(databaseTypeForNode(node)));
|
||||||
|
if (targets.every((node) => node.type === "table" && node.connectionId && node.database)) {
|
||||||
|
const first = targets[0]!;
|
||||||
|
await connectionStore.ensureConnected(first.connectionId!);
|
||||||
|
batchDropProgress.value = { completed: 0, total: targets.length };
|
||||||
|
const plan = await Promise.all(
|
||||||
|
targets.map(async (target) => {
|
||||||
|
const sql = await dropSqlForTreeNode(target, { cascade: useCascade });
|
||||||
|
if (!sql) throw new Error("Drop table SQL is unavailable");
|
||||||
|
return { target, sql };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const batchSql = plan.map(({ sql }) => sql).join(";\n");
|
||||||
|
const result = await executeWithProductionSqlGuard({
|
||||||
|
connection: connectionStore.getConfig(first.connectionId!),
|
||||||
|
database: first.database!,
|
||||||
|
sql: batchSql,
|
||||||
|
source: t("production.sourceSidebar"),
|
||||||
|
execute: () =>
|
||||||
|
runBatchTableDrop({
|
||||||
|
databaseType: databaseTypeForNode(first),
|
||||||
|
plan,
|
||||||
|
executeStatement: (sql) => api.executeQuery(first.connectionId!, first.database!, sql),
|
||||||
|
executeBatch: (sql, onProgress) => api.executeMultiWithProgress(first.connectionId!, first.database!, sql, onProgress),
|
||||||
|
onProgress: (progress) => {
|
||||||
|
batchDropProgress.value = { completed: Math.min(progress.completed, targets.length), total: targets.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
for (const target of result.succeeded) {
|
||||||
|
closeDroppedTableObjectTabsForNode(target);
|
||||||
|
connectionStore.removeTreeNode(target.id);
|
||||||
|
releaseActiveNodeReference([target.id]);
|
||||||
|
}
|
||||||
|
if (result.failed) throw result.failed;
|
||||||
|
toast(t("contextMenu.batchDropSuccess", { count: result.succeeded.length }), 3000);
|
||||||
|
showBatchDropConfirm.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
if (!target.connectionId || !target.database) continue;
|
if (!target.connectionId || !target.database) continue;
|
||||||
await connectionStore.ensureConnected(target.connectionId);
|
await connectionStore.ensureConnected(target.connectionId);
|
||||||
@@ -3186,6 +3230,9 @@ routeDangerDialog(showBatchDropConfirm, () =>
|
|||||||
return batchDropPreviewSql.value;
|
return batchDropPreviewSql.value;
|
||||||
},
|
},
|
||||||
confirmLabel: batchDropMenuLabel(),
|
confirmLabel: batchDropMenuLabel(),
|
||||||
|
get progress() {
|
||||||
|
return batchDropProgress.value.total > 0 ? batchDropProgress.value : undefined;
|
||||||
|
},
|
||||||
option: canBatchDropCascade.value
|
option: canBatchDropCascade.value
|
||||||
? {
|
? {
|
||||||
checked: batchDropCascade.value,
|
checked: batchDropCascade.value,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
|
||||||
|
import type { QueryResult } from "@/types/database";
|
||||||
|
|
||||||
|
function queryResult(overrides: Partial<QueryResult> = {}): QueryResult {
|
||||||
|
return { columns: [], rows: [], affected_rows: 0, execution_time_ms: 1, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = [
|
||||||
|
{ target: "orders", sql: "DROP TABLE orders" },
|
||||||
|
{ target: "customers", sql: "DROP TABLE customers" },
|
||||||
|
{ target: "events", sql: "DROP TABLE events" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("batch table drop", () => {
|
||||||
|
it("keeps SQL Server sequential so progress and partial failure stay attributable", async () => {
|
||||||
|
const executeStatement = vi.fn(async (sql: string) => {
|
||||||
|
if (sql.includes("customers")) throw new Error("permission denied");
|
||||||
|
});
|
||||||
|
const executeBatch = vi.fn();
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
|
||||||
|
const result = await runBatchTableDrop({ databaseType: "sqlserver", plan, executeStatement, executeBatch, onProgress });
|
||||||
|
|
||||||
|
expect(executeStatement.mock.calls.map(([sql]) => sql)).toEqual(["DROP TABLE orders", "DROP TABLE customers"]);
|
||||||
|
expect(executeBatch).not.toHaveBeenCalled();
|
||||||
|
expect(result.succeeded).toEqual(["orders"]);
|
||||||
|
expect(result.failed?.message).toBe("permission denied");
|
||||||
|
expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([
|
||||||
|
{ completed: 1, total: 3, success: true },
|
||||||
|
{ completed: 2, total: 3, success: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps indexed batch results back to their exact targets", async () => {
|
||||||
|
const result = await runBatchTableDrop({
|
||||||
|
databaseType: "mysql",
|
||||||
|
plan,
|
||||||
|
executeStatement: vi.fn(),
|
||||||
|
executeBatch: async () => [queryResult({ statement_index: 0 }), queryResult({ statement_index: 1, execution_error: true, columns: ["Error"], rows: [["locked"]] })],
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.succeeded).toEqual(["orders"]);
|
||||||
|
expect(result.failed?.message).toBe("locked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a successful atomic HTTP SQLite batch as all-or-nothing", async () => {
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const result = await runBatchTableDrop({
|
||||||
|
databaseType: "turso",
|
||||||
|
plan,
|
||||||
|
executeStatement: vi.fn(),
|
||||||
|
executeBatch: async () => [queryResult()],
|
||||||
|
onProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.succeeded).toEqual(["orders", "customers", "events"]);
|
||||||
|
expect(result.failed).toBeUndefined();
|
||||||
|
expect(onProgress).toHaveBeenLastCalledWith({ completed: 3, total: 3, success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when a non-atomic batch omits statement indexes", async () => {
|
||||||
|
const result = await runBatchTableDrop({
|
||||||
|
databaseType: "postgres",
|
||||||
|
plan,
|
||||||
|
executeStatement: vi.fn(),
|
||||||
|
executeBatch: async () => [queryResult()],
|
||||||
|
onProgress: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.succeeded).toEqual([]);
|
||||||
|
expect(result.failed?.message).toBe("Batch drop did not report a result for every statement");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,6 +179,7 @@ export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
|
|||||||
// Query
|
// Query
|
||||||
export const executeQuery = forward("executeQuery");
|
export const executeQuery = forward("executeQuery");
|
||||||
export const executeMulti = forward("executeMulti");
|
export const executeMulti = forward("executeMulti");
|
||||||
|
export const executeMultiWithProgress = forward("executeMultiWithProgress");
|
||||||
export const executeBatch = forward("executeBatch");
|
export const executeBatch = forward("executeBatch");
|
||||||
export const executeScript = forward("executeScript");
|
export const executeScript = forward("executeScript");
|
||||||
export const executeInTransaction = forward("executeInTransaction");
|
export const executeInTransaction = forward("executeInTransaction");
|
||||||
|
|||||||
@@ -820,6 +820,37 @@ export async function executeMulti(
|
|||||||
return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options });
|
return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExecuteMultiProgress {
|
||||||
|
executionId: string;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeMultiWithProgress(
|
||||||
|
connectionId: string,
|
||||||
|
database: string,
|
||||||
|
sql: string,
|
||||||
|
onProgress: (progress: ExecuteMultiProgress) => void,
|
||||||
|
schema?: string,
|
||||||
|
options?: {
|
||||||
|
maxRows?: number;
|
||||||
|
fetchSize?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
resultSessionId?: string;
|
||||||
|
clientSessionId?: string;
|
||||||
|
timeoutSecs?: number;
|
||||||
|
useTransaction?: boolean;
|
||||||
|
continueOnError?: boolean;
|
||||||
|
executionMode?: "simple";
|
||||||
|
},
|
||||||
|
): 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) });
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export async function closeQuerySession(connectionId: string, database: string, sessionId: string, clientSessionId?: string): Promise<boolean> {
|
export async function closeQuerySession(connectionId: string, database: string, sessionId: string, clientSessionId?: string): Promise<boolean> {
|
||||||
return post("/api/query/close-session", { connectionId, database, sessionId, clientSessionId });
|
return post("/api/query/close-session", { connectionId, database, sessionId, clientSessionId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -939,6 +939,42 @@ export async function executeMulti(
|
|||||||
return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options });
|
return invoke("execute_multi", { connectionId, database, sql, schema, executionId, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExecuteMultiProgress {
|
||||||
|
executionId: string;
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeMultiWithProgress(
|
||||||
|
connectionId: string,
|
||||||
|
database: string,
|
||||||
|
sql: string,
|
||||||
|
onProgress: (progress: ExecuteMultiProgress) => void,
|
||||||
|
schema?: string,
|
||||||
|
options?: {
|
||||||
|
maxRows?: number;
|
||||||
|
fetchSize?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
resultSessionId?: string;
|
||||||
|
clientSessionId?: string;
|
||||||
|
timeoutSecs?: number;
|
||||||
|
useTransaction?: boolean;
|
||||||
|
continueOnError?: boolean;
|
||||||
|
executionMode?: "simple";
|
||||||
|
},
|
||||||
|
): Promise<QueryResult[]> {
|
||||||
|
const executionId = crypto.randomUUID();
|
||||||
|
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 });
|
||||||
|
} finally {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshConnections(): Promise<void> {
|
export async function refreshConnections(): Promise<void> {
|
||||||
return invoke("refresh_connections");
|
return invoke("refresh_connections");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export interface SidebarDangerDialogOption {
|
|||||||
onChange?: (checked: boolean) => void | Promise<void>;
|
onChange?: (checked: boolean) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SidebarDangerDialogProgress {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SidebarDangerDialogRequest {
|
export interface SidebarDangerDialogRequest {
|
||||||
target: SidebarActionTarget;
|
target: SidebarActionTarget;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -17,6 +22,7 @@ export interface SidebarDangerDialogRequest {
|
|||||||
detailsText?: string;
|
detailsText?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
closeOnConfirm?: boolean;
|
closeOnConfirm?: boolean;
|
||||||
|
progress?: SidebarDangerDialogProgress;
|
||||||
option?: SidebarDangerDialogOption;
|
option?: SidebarDangerDialogOption;
|
||||||
confirm: () => void | Promise<void>;
|
confirm: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||||
|
|
||||||
|
export interface BatchTableDropPlanItem<T> {
|
||||||
|
target: T;
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchTableDropProgress {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchTableDropResult<T> {
|
||||||
|
succeeded: T[];
|
||||||
|
failed?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunBatchTableDropOptions<T> {
|
||||||
|
databaseType: DatabaseType | undefined;
|
||||||
|
plan: readonly BatchTableDropPlanItem<T>[];
|
||||||
|
executeStatement: (sql: string) => Promise<unknown>;
|
||||||
|
executeBatch: (sql: string, onProgress: (progress: BatchTableDropProgress) => void) => Promise<QueryResult[]>;
|
||||||
|
onProgress: (progress: BatchTableDropProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function batchDropError(result: QueryResult): Error {
|
||||||
|
return new Error(String(result.rows[0]?.[0] ?? "Batch drop failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAtomicIndexlessBatch(databaseType: DatabaseType | undefined): boolean {
|
||||||
|
return databaseType === "turso" || databaseType === "cloudflare-d1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBatchTableDrop<T>({ databaseType, plan, executeStatement, executeBatch, onProgress }: RunBatchTableDropOptions<T>): Promise<BatchTableDropResult<T>> {
|
||||||
|
if (databaseType === "sqlserver") {
|
||||||
|
const succeeded: T[] = [];
|
||||||
|
for (let index = 0; index < plan.length; index += 1) {
|
||||||
|
try {
|
||||||
|
await executeStatement(plan[index]!.sql);
|
||||||
|
succeeded.push(plan[index]!.target);
|
||||||
|
onProgress({ completed: index + 1, total: plan.length, success: true });
|
||||||
|
} catch (error) {
|
||||||
|
onProgress({ completed: index + 1, total: plan.length, success: false });
|
||||||
|
return { succeeded, failed: error instanceof Error ? error : new Error(String(error)) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { succeeded };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await executeBatch(plan.map(({ sql }) => sql).join(";\n"), onProgress);
|
||||||
|
const failedResult = results.find((result) => result.execution_error === true);
|
||||||
|
if (!failedResult && isAtomicIndexlessBatch(databaseType) && results.every((result) => !Number.isInteger(result.statement_index))) {
|
||||||
|
onProgress({ completed: plan.length, total: plan.length, success: true });
|
||||||
|
return { succeeded: plan.map(({ target }) => target) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeededIndexes = new Set(results.filter((result) => result.execution_error !== true && Number.isInteger(result.statement_index)).map((result) => result.statement_index!));
|
||||||
|
const succeeded = plan.filter((_, index) => succeededIndexes.has(index)).map(({ target }) => target);
|
||||||
|
if (failedResult) return { succeeded, failed: batchDropError(failedResult) };
|
||||||
|
if (succeeded.length !== plan.length) {
|
||||||
|
return { succeeded, failed: new Error("Batch drop did not report a result for every statement") };
|
||||||
|
}
|
||||||
|
return { succeeded };
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ pub struct ExecuteMultiResult {
|
|||||||
pub statement_index: Option<usize>,
|
pub statement_index: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub type ExecuteMultiProgressCallback = Arc<dyn Fn(usize, usize, bool) + Send + Sync>;
|
||||||
|
|
||||||
impl ExecuteMultiResult {
|
impl ExecuteMultiResult {
|
||||||
fn execution_error(result: db::QueryResult) -> Self {
|
fn execution_error(result: db::QueryResult) -> Self {
|
||||||
Self { result, execution_error: true, statement_index: None }
|
Self { result, execution_error: true, statement_index: None }
|
||||||
@@ -2019,6 +2021,30 @@ pub async fn execute_multi_core_with_options_for_client(
|
|||||||
schema: Option<&str>,
|
schema: Option<&str>,
|
||||||
cancel_token: Option<CancellationToken>,
|
cancel_token: Option<CancellationToken>,
|
||||||
options: QueryExecutionOptions,
|
options: QueryExecutionOptions,
|
||||||
|
) -> Result<Vec<ExecuteMultiResult>, String> {
|
||||||
|
execute_multi_core_with_options_for_client_and_progress(
|
||||||
|
state,
|
||||||
|
connection_id,
|
||||||
|
database,
|
||||||
|
sql,
|
||||||
|
schema,
|
||||||
|
cancel_token,
|
||||||
|
options,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes a SQL batch and reports each completed statement to the optional callback.
|
||||||
|
pub async fn execute_multi_core_with_options_for_client_and_progress(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
database: &str,
|
||||||
|
sql: &str,
|
||||||
|
schema: Option<&str>,
|
||||||
|
cancel_token: Option<CancellationToken>,
|
||||||
|
options: QueryExecutionOptions,
|
||||||
|
progress: Option<ExecuteMultiProgressCallback>,
|
||||||
) -> Result<Vec<ExecuteMultiResult>, String> {
|
) -> Result<Vec<ExecuteMultiResult>, String> {
|
||||||
// Reject MongoDB queries that fall through to the generic executor.
|
// Reject MongoDB queries that fall through to the generic executor.
|
||||||
if connection_is_mongodb(state, connection_id).await {
|
if connection_is_mongodb(state, connection_id).await {
|
||||||
@@ -2122,6 +2148,7 @@ pub async fn execute_multi_core_with_options_for_client(
|
|||||||
&statements,
|
&statements,
|
||||||
cancel_token,
|
cancel_token,
|
||||||
options,
|
options,
|
||||||
|
progress.as_ref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -2143,10 +2170,18 @@ pub async fn execute_multi_core_with_options_for_client(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) => results.push(ExecuteMultiResult::success_with_index(r, statement_index)),
|
Ok(r) => {
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
let action = query_pool_error_action(db_type, stmt, &e);
|
let action = query_pool_error_action(db_type, stmt, &e);
|
||||||
results.push(ExecuteMultiResult::execution_error_with_index(error_query_result(e), statement_index));
|
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);
|
||||||
|
}
|
||||||
if !should_continue_batch_after_error(options.continue_on_error, action) {
|
if !should_continue_batch_after_error(options.continue_on_error, action) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -2193,6 +2228,7 @@ async fn execute_mysql_batch_statements<E>(
|
|||||||
db_type: Option<DatabaseType>,
|
db_type: Option<DatabaseType>,
|
||||||
cancel_token: Option<CancellationToken>,
|
cancel_token: Option<CancellationToken>,
|
||||||
continue_on_error: bool,
|
continue_on_error: bool,
|
||||||
|
progress: Option<&ExecuteMultiProgressCallback>,
|
||||||
) -> (Vec<ExecuteMultiResult>, Option<PoolErrorAction>)
|
) -> (Vec<ExecuteMultiResult>, Option<PoolErrorAction>)
|
||||||
where
|
where
|
||||||
E: MysqlBatchStatementExecutor,
|
E: MysqlBatchStatementExecutor,
|
||||||
@@ -2205,10 +2241,18 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
match executor.execute_statement(statement).await {
|
match executor.execute_statement(statement).await {
|
||||||
Ok(result) => results.push(ExecuteMultiResult::success_with_index(result, statement_index)),
|
Ok(result) => {
|
||||||
|
results.push(ExecuteMultiResult::success_with_index(result, statement_index));
|
||||||
|
if let Some(progress) = progress {
|
||||||
|
progress(statement_index + 1, statements.len(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let action = pool_error_action(db_type, &err);
|
let action = pool_error_action(db_type, &err);
|
||||||
results.push(ExecuteMultiResult::execution_error_with_index(error_query_result(err), statement_index));
|
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);
|
||||||
|
}
|
||||||
// Statement errors are safe to collect, but connection-level failures leave
|
// Statement errors are safe to collect, but connection-level failures leave
|
||||||
// the protocol state unusable and must still trigger pool cleanup.
|
// the protocol state unusable and must still trigger pool cleanup.
|
||||||
if !should_continue_batch_after_error(continue_on_error, action) {
|
if !should_continue_batch_after_error(continue_on_error, action) {
|
||||||
@@ -2231,6 +2275,7 @@ async fn execute_multi_mysql(
|
|||||||
statements: &[String],
|
statements: &[String],
|
||||||
cancel_token: Option<CancellationToken>,
|
cancel_token: Option<CancellationToken>,
|
||||||
options: QueryExecutionOptions,
|
options: QueryExecutionOptions,
|
||||||
|
progress: Option<&ExecuteMultiProgressCallback>,
|
||||||
) -> Result<Vec<ExecuteMultiResult>, String> {
|
) -> Result<Vec<ExecuteMultiResult>, String> {
|
||||||
let query_timeout = resolve_query_timeout(options.timeout_secs);
|
let query_timeout = resolve_query_timeout(options.timeout_secs);
|
||||||
let operation_budget = operation_budget_for_pool_key(state, pool_key, query_timeout).await;
|
let operation_budget = operation_budget_for_pool_key(state, pool_key, query_timeout).await;
|
||||||
@@ -2264,9 +2309,15 @@ async fn execute_multi_mysql(
|
|||||||
max_rows,
|
max_rows,
|
||||||
dialect,
|
dialect,
|
||||||
};
|
};
|
||||||
let (results, error_action) =
|
let (results, error_action) = execute_mysql_batch_statements(
|
||||||
execute_mysql_batch_statements(&mut executor, statements, db_type, cancel_token, options.continue_on_error)
|
&mut executor,
|
||||||
.await;
|
statements,
|
||||||
|
db_type,
|
||||||
|
cancel_token,
|
||||||
|
options.continue_on_error,
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
drop(executor);
|
drop(executor);
|
||||||
|
|
||||||
if matches!(error_action, Some(PoolErrorAction::Discard | PoolErrorAction::ReconnectAndRetry)) {
|
if matches!(error_action, Some(PoolErrorAction::Discard | PoolErrorAction::ReconnectAndRetry)) {
|
||||||
@@ -3769,7 +3820,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (results, error_action) =
|
let (results, error_action) =
|
||||||
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, false).await;
|
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, false, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(executor.executed, vec!["first", "fails"]);
|
assert_eq!(executor.executed, vec!["first", "fails"]);
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
@@ -3779,6 +3831,35 @@ mod tests {
|
|||||||
assert_eq!(error_action, Some(PoolErrorAction::Keep));
|
assert_eq!(error_action, Some(PoolErrorAction::Keep));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mysql_batch_reports_progress_for_each_completed_statement() {
|
||||||
|
let statements = vec!["first".to_string(), "fails".to_string(), "must-not-run".to_string()];
|
||||||
|
let mut executor = FakeMysqlBatchExecutor {
|
||||||
|
outcomes: std::collections::VecDeque::from([Ok(empty_query_result(0)), Err("Duplicate entry".to_string())]),
|
||||||
|
executed: Vec::new(),
|
||||||
|
};
|
||||||
|
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)))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (results, error_action) = execute_mysql_batch_statements(
|
||||||
|
&mut executor,
|
||||||
|
&statements,
|
||||||
|
Some(DatabaseType::Mysql),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
Some(&progress),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
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!(error_action, Some(PoolErrorAction::Keep));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mysql_batch_stops_when_the_first_statement_fails() {
|
async fn mysql_batch_stops_when_the_first_statement_fails() {
|
||||||
let statements = vec!["fails".to_string(), "must-not-run".to_string()];
|
let statements = vec!["fails".to_string(), "must-not-run".to_string()];
|
||||||
@@ -3788,7 +3869,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (results, error_action) =
|
let (results, error_action) =
|
||||||
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, false).await;
|
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, false, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(executor.executed, vec!["fails"]);
|
assert_eq!(executor.executed, vec!["fails"]);
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
@@ -3809,7 +3891,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (results, error_action) =
|
let (results, error_action) =
|
||||||
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, true).await;
|
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, true, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(executor.executed, statements);
|
assert_eq!(executor.executed, statements);
|
||||||
assert_eq!(results.len(), 3);
|
assert_eq!(results.len(), 3);
|
||||||
@@ -3830,7 +3913,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (results, error_action) =
|
let (results, error_action) =
|
||||||
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, true).await;
|
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, true, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(executor.executed, statements);
|
assert_eq!(executor.executed, statements);
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
@@ -3851,7 +3935,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (results, error_action) =
|
let (results, error_action) =
|
||||||
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, true).await;
|
execute_mysql_batch_statements(&mut executor, &statements, Some(DatabaseType::Mysql), None, true, None)
|
||||||
|
.await;
|
||||||
|
|
||||||
assert_eq!(executor.executed, vec!["first", "disconnects"]);
|
assert_eq!(executor.executed, vec!["first", "disconnects"]);
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(results.len(), 2);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri::State;
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
use crate::commands::connection::AppState;
|
use crate::commands::connection::AppState;
|
||||||
use dbx_core::db;
|
use dbx_core::db;
|
||||||
@@ -8,6 +8,15 @@ use dbx_core::models::connection::DatabaseType;
|
|||||||
use dbx_core::query_cancel::RunningTaskMetadata;
|
use dbx_core::query_cancel::RunningTaskMetadata;
|
||||||
use dbx_core::sql::split_sql_statements;
|
use dbx_core::sql::split_sql_statements;
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExecuteMultiProgress {
|
||||||
|
execution_id: String,
|
||||||
|
completed: usize,
|
||||||
|
total: usize,
|
||||||
|
success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn execute_query(
|
pub async fn execute_query(
|
||||||
@@ -59,6 +68,7 @@ pub async fn execute_query(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn execute_multi(
|
pub async fn execute_multi(
|
||||||
|
app: AppHandle,
|
||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
database: String,
|
database: String,
|
||||||
@@ -83,6 +93,16 @@ pub async fn execute_multi(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let cancel_token = registered_query.as_ref().map(|query| query.token());
|
let cancel_token = registered_query.as_ref().map(|query| query.token());
|
||||||
|
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| {
|
||||||
|
let _ = app.emit(
|
||||||
|
"query-batch-progress",
|
||||||
|
ExecuteMultiProgress { execution_id: execution_id.clone(), completed, total, success },
|
||||||
|
);
|
||||||
|
}) as dbx_core::query::ExecuteMultiProgressCallback
|
||||||
|
});
|
||||||
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id").to_string();
|
let trace_id = execution_id.as_deref().unwrap_or("no-execution-id").to_string();
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
dbx_core::sql_diagnostics::debug_sql("query:execute_multi:start", &sql);
|
dbx_core::sql_diagnostics::debug_sql("query:execute_multi:start", &sql);
|
||||||
@@ -94,7 +114,7 @@ pub async fn execute_multi(
|
|||||||
schema
|
schema
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = dbx_core::query::execute_multi_core_with_options_for_client(
|
let result = dbx_core::query::execute_multi_core_with_options_for_client_and_progress(
|
||||||
&state,
|
&state,
|
||||||
&connection_id,
|
&connection_id,
|
||||||
&database,
|
&database,
|
||||||
@@ -113,6 +133,7 @@ pub async fn execute_multi(
|
|||||||
continue_on_error: continue_on_error.unwrap_or(false),
|
continue_on_error: continue_on_error.unwrap_or(false),
|
||||||
execution_mode: execution_mode.unwrap_or_default(),
|
execution_mode: execution_mode.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
|
progress,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match &result {
|
match &result {
|
||||||
|
|||||||
Reference in new issue
Block a user