fix(objects): add safe object browser batch empty action
This commit is contained in:
3 files changed
+118
-7
No files matched your search
@@ -80,6 +80,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||||
import DdlViewDialog from "./DdlViewDialog.vue";
|
||||
import { formatSqlForDisplay, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
buildObjectBrowserRows,
|
||||
@@ -202,6 +203,9 @@ const batchDropPreviewSql = ref("");
|
||||
const showBatchTruncateConfirm = ref(false);
|
||||
const batchTruncatePreviewSql = ref("");
|
||||
const batchTruncateCascade = ref(false);
|
||||
const showBatchEmptyConfirm = ref(false);
|
||||
const batchEmptyPreviewSql = ref("");
|
||||
const batchEmptyPlan = ref<BatchTableEmptyPlanItem<ObjectBrowserRow>[]>([]);
|
||||
// Paste table dialog state
|
||||
const showPasteDialog = ref(false);
|
||||
const pasteTableMode = ref<PasteTableMode>("structure-and-data");
|
||||
@@ -1455,6 +1459,57 @@ async function confirmBatchTruncateTables() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshBatchEmptyPreviewSql(targets: ObjectBrowserRow[]) {
|
||||
const plan = await buildBatchTableEmptyPlan(targets, (row) => buildEmptyTableSql(tableAdminSqlOptions(row)));
|
||||
// Freeze the reviewed SQL with its target so confirmation cannot execute a different destructive statement.
|
||||
batchEmptyPlan.value = plan;
|
||||
batchEmptyPreviewSql.value = plan.map(({ sql }) => sql).join("\n");
|
||||
}
|
||||
|
||||
function requestBatchEmptyTables() {
|
||||
const targets = [...selectedTableRows.value];
|
||||
if (targets.length === 0) return;
|
||||
batchEmptyPlan.value = [];
|
||||
batchEmptyPreviewSql.value = "";
|
||||
void refreshBatchEmptyPreviewSql(targets)
|
||||
.then(() => {
|
||||
showBatchEmptyConfirm.value = true;
|
||||
})
|
||||
.catch((e: any) => {
|
||||
batchEmptyPlan.value = [];
|
||||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmBatchEmptyTables() {
|
||||
const plan = batchEmptyPlan.value.slice();
|
||||
if (plan.length === 0) return;
|
||||
const asynchronousMutation = effectiveDatabaseType.value === "clickhouse";
|
||||
const result = await runBatchTableEmpty(plan, async ({ sql }) => {
|
||||
await api.executeQuery(props.connection.id, props.database, sql);
|
||||
});
|
||||
for (const failure of result.failed) {
|
||||
console.error(`Failed to empty table "${failure.target.target.name}":`, failure.error);
|
||||
}
|
||||
const feedback = batchTableEmptyFeedback(result, asynchronousMutation);
|
||||
if (feedback === "success") {
|
||||
toast(t("contextMenu.batchEmptySuccess", { count: result.succeeded.length }), 3000);
|
||||
} else if (feedback === "submitted") {
|
||||
toast(t("contextMenu.batchEmptySubmitted", { count: result.succeeded.length }), 3000);
|
||||
} else if (feedback === "submitted-partial") {
|
||||
toast(t("contextMenu.batchEmptySubmittedPartial", { success: result.succeeded.length, failed: result.failed.length }), 5000);
|
||||
} else {
|
||||
toast(t("contextMenu.batchEmptyPartialFail", { success: result.succeeded.length, failed: result.failed.length }), 5000);
|
||||
}
|
||||
batchEmptyPlan.value = [];
|
||||
showBatchEmptyConfirm.value = false;
|
||||
if (result.succeeded.length > 0) {
|
||||
clearTableSelection();
|
||||
await reload();
|
||||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportStructure(row: ObjectBrowserRow) {
|
||||
try {
|
||||
const schema = row.schema || selectedSchema.value || props.database;
|
||||
@@ -2075,7 +2130,16 @@ function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
|
||||
};
|
||||
}
|
||||
|
||||
function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean {
|
||||
return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
|
||||
}
|
||||
|
||||
function selectedBatchTableCountLabel(key: "batchDrop" | "batchTruncate" | "batchEmpty"): string {
|
||||
return t(`contextMenu.${key}`, { count: selectedTableCount.value });
|
||||
}
|
||||
|
||||
function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
const useBatchActions = isSelectedBatchTableContext(item);
|
||||
return [
|
||||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||||
{
|
||||
@@ -2103,22 +2167,22 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
...(supportsTruncateTable.value
|
||||
? [
|
||||
{
|
||||
label: t("contextMenu.truncateTable"),
|
||||
action: () => requestTruncateTable(item),
|
||||
label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"),
|
||||
action: useBatchActions ? requestBatchTruncateTables : () => requestTruncateTable(item),
|
||||
icon: Scissors,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t("contextMenu.emptyTable"),
|
||||
action: () => requestEmptyTable(item),
|
||||
label: useBatchActions ? selectedBatchTableCountLabel("batchEmpty") : t("contextMenu.emptyTable"),
|
||||
action: useBatchActions ? requestBatchEmptyTables : () => requestEmptyTable(item),
|
||||
icon: Eraser,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
{
|
||||
label: t("contextMenu.dropTable"),
|
||||
action: () => requestDrop(item),
|
||||
label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"),
|
||||
action: useBatchActions ? requestBatchDropTables : () => requestDrop(item),
|
||||
icon: Trash2,
|
||||
variant: "destructive" as const,
|
||||
},
|
||||
@@ -2293,6 +2357,10 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
<Scissors class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("objects.truncateSelected") }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
|
||||
<Eraser class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
|
||||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||||
{{ t("objects.dropSelected") }}
|
||||
@@ -2774,6 +2842,15 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||||
</template>
|
||||
</DangerConfirmDialog>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="showBatchEmptyConfirm"
|
||||
:title="t('contextMenu.confirmBatchEmptyTitle', { count: batchEmptyPlan.length })"
|
||||
:message="t('contextMenu.confirmBatchEmptyMessage', { count: batchEmptyPlan.length })"
|
||||
:sql="batchEmptyPreviewSql"
|
||||
:confirm-label="t('contextMenu.batchEmpty', { count: batchEmptyPlan.length })"
|
||||
@confirm="confirmBatchEmptyTables"
|
||||
/>
|
||||
|
||||
<Dialog v-model:open="showRenameDialog">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
|
||||
Reference in new issue
Block a user