mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(transactions): add manual transactions to more execution paths
This commit is contained in:
@@ -963,6 +963,7 @@ function requestActiveEditorPreviewChanges() {
|
||||
const multiExecuteDatabaseType = ref<DatabaseType>();
|
||||
const multiExecuteInitialTargets = ref<Array<{ connectionId: string; catalog?: string; database: string; schema?: string }>>([]);
|
||||
const multiExecuteLaunchId = ref(0);
|
||||
const multiExecuteManualTransaction = ref(false);
|
||||
// Launch-time input only. MultiDbExecuteDialog copies this into its immutable
|
||||
// batch context before the first target starts; execution never reads this ref.
|
||||
const multiExecuteSourceOffset = ref<number>();
|
||||
@@ -972,7 +973,7 @@ function multiExecuteTargetLabel(target: { connectionId: string; catalog?: strin
|
||||
return [connection?.name || target.connectionId, target.catalog, target.database, target.schema].filter((value) => value !== undefined && value !== "").join(" / ");
|
||||
}
|
||||
|
||||
async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number }; isCancellationRequested: () => boolean }) {
|
||||
async function executeMultiDbTarget(input: { target: { connectionId: string; catalog?: string; database: string; schema?: string }; sourceTabId: string; sql: string; scopeId: string; context: { sourceOffset?: number; manualTransaction?: boolean }; isCancellationRequested: () => boolean }) {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === input.sourceTabId);
|
||||
const connection = connectionStore.getConfig(input.target.connectionId);
|
||||
if (!tab || !connection) return { status: "failed" as const, errorMessage: t("multiDbExecute.targetMissingConnection") };
|
||||
@@ -987,6 +988,7 @@ async function executeMultiDbTarget(input: { target: { connectionId: string; cat
|
||||
target: input.target,
|
||||
},
|
||||
sourceOffset: input.context.sourceOffset,
|
||||
manualTransaction: input.context.manualTransaction,
|
||||
blockDangerousRedisCommands: blockDangerousRedisCommands.value,
|
||||
targetLabel: multiExecuteTargetLabel(input.target),
|
||||
scopeId: input.scopeId,
|
||||
@@ -1006,7 +1008,7 @@ function cancelPendingMultiDbTarget(scopeId: string): void {
|
||||
|
||||
async function requestMultiDbExecute() {
|
||||
const tab = activeTab.value;
|
||||
if (!tab || !activeConnection.value || showMultiDbExecuteDialog.value) return;
|
||||
if (!tab || !activeConnection.value || showMultiDbExecuteDialog.value || tab.txnSessionId) return;
|
||||
const sourceTabId = tab.id;
|
||||
const sourceConnection = connectionStore.getConfig(tab.connectionId) ?? activeConnection.value;
|
||||
const sourceTarget = normalizeSqlExecutionTarget(sourceConnection, {
|
||||
@@ -1020,6 +1022,7 @@ async function requestMultiDbExecute() {
|
||||
multiExecuteSourceOffset.value = sourceOffset;
|
||||
multiExecuteLaunchId.value += 1;
|
||||
multiExecuteSourceTabId.value = sourceTabId;
|
||||
multiExecuteManualTransaction.value = tab.autoCommit === false;
|
||||
multiExecuteDatabaseType.value = effectiveDatabaseTypeForConnection(sourceConnection);
|
||||
multiExecuteInitialTargets.value = [sourceTarget];
|
||||
showMultiDbExecuteDialog.value = true;
|
||||
@@ -4388,6 +4391,7 @@ onUnmounted(() => {
|
||||
:initial-targets="multiExecuteInitialTargets"
|
||||
:launch-id="multiExecuteLaunchId"
|
||||
:execute-target="executeMultiDbTarget"
|
||||
:initial-manual-transaction="multiExecuteManualTransaction"
|
||||
:cancel-target="cancelMultiDbTarget"
|
||||
:cancel-pending="cancelPendingMultiDbTarget"
|
||||
:source-offset="multiExecuteSourceOffset"
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
import CompareKeyColumnsSelect from "@/components/diff/CompareKeyColumnsSelect.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
|
||||
import TableMultiSelect from "@/components/diff/TableMultiSelect.vue";
|
||||
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, RotateCcw, Square } from "@lucide/vue";
|
||||
|
||||
@@ -75,6 +77,14 @@ const compareProgressCurrent = ref(0);
|
||||
const compareProgressTotal = ref(0);
|
||||
const compareProgressTable = ref("");
|
||||
const executing = ref(false);
|
||||
const manualTransaction = ref(false);
|
||||
const txnSessionId = ref<string>();
|
||||
let commitUncertain = false;
|
||||
const resolvingTransaction = ref(false);
|
||||
const transactionFailed = ref(false);
|
||||
let executionInterrupted = false;
|
||||
const executionLocked = computed(() => executing.value || resolvingTransaction.value || !!txnSessionId.value);
|
||||
const canUseManualTransaction = computed(() => supportsTransaction(store.getConfig(targetConnectionId.value)?.db_type));
|
||||
const executedCount = ref(0);
|
||||
const executeTotal = ref(0);
|
||||
const syncErrors = ref<{ sql: string; error: string }[]>([]);
|
||||
@@ -465,6 +475,7 @@ function applyDataCompareSession(session: DataCompareSession | undefined): void
|
||||
}
|
||||
|
||||
function swapSourceTarget() {
|
||||
if (executionLocked.value) return;
|
||||
const previousSelectedTables = [...selectedSourceTableNames.value];
|
||||
const nextSingleTarget = previousSelectedTables.length === 1 ? (previousSelectedTables[0] ?? "") : "";
|
||||
const nextSourceSelection = previousSelectedTables.length <= 1 ? [targetTable.value].filter(Boolean) : previousSelectedTables;
|
||||
@@ -622,6 +633,7 @@ function toggleShowAll(table: DataCompareTableResult, kind: DiffKind) {
|
||||
}
|
||||
|
||||
function setDiffSelection(kind: DiffKind, selected: boolean) {
|
||||
if (executionLocked.value) return;
|
||||
batchResults.value.forEach((table) => {
|
||||
table.diff[kind].forEach((row) => {
|
||||
row.selected = selected;
|
||||
@@ -631,6 +643,7 @@ function setDiffSelection(kind: DiffKind, selected: boolean) {
|
||||
}
|
||||
|
||||
function setTableDiffSelection(table: DataCompareTableResult, kind: DiffKind, selected: boolean) {
|
||||
if (executionLocked.value) return;
|
||||
table.diff[kind].forEach((row) => {
|
||||
row.selected = selected;
|
||||
});
|
||||
@@ -638,6 +651,7 @@ function setTableDiffSelection(table: DataCompareTableResult, kind: DiffKind, se
|
||||
}
|
||||
|
||||
function clearAllSelections() {
|
||||
if (executionLocked.value) return;
|
||||
(["added", "removed", "modified"] as DiffKind[]).forEach((kind) => {
|
||||
batchResults.value.forEach((table) => {
|
||||
table.diff[kind].forEach((row) => {
|
||||
@@ -649,6 +663,7 @@ function clearAllSelections() {
|
||||
}
|
||||
|
||||
function toggleRowSelection(row: SelectableDataCompareRow | SelectableDataCompareModifiedRow) {
|
||||
if (executionLocked.value) return;
|
||||
row.selected = !row.selected;
|
||||
rebuildSyncPlan().catch((e) => toast(String(e), 5000));
|
||||
}
|
||||
@@ -695,7 +710,7 @@ async function rebuildSyncPlan() {
|
||||
}
|
||||
|
||||
function startCompare(): void {
|
||||
if (!canCompare.value || comparing.value || executing.value) return;
|
||||
if (!canCompare.value || comparing.value || executionLocked.value) return;
|
||||
const tasks = buildCompareTasks();
|
||||
if (tasks.length === 0) {
|
||||
toast(t("dataCompare.noComparableTables"), 5000);
|
||||
@@ -747,30 +762,46 @@ async function copySql() {
|
||||
}
|
||||
|
||||
async function executeSql() {
|
||||
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
|
||||
const targetConnection = store.getConfig(targetConnectionId.value);
|
||||
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || planningSync.value || comparing.value || executionLocked.value) return;
|
||||
const connectionId = targetConnectionId.value;
|
||||
const database = targetDatabase.value;
|
||||
const schema = targetSchema.value;
|
||||
const statements = [...syncPlan.value.syncStatements];
|
||||
const useTransaction = manualTransaction.value && canUseManualTransaction.value;
|
||||
const targetConnection = store.getConfig(connectionId);
|
||||
executing.value = true;
|
||||
executionInterrupted = false;
|
||||
transactionFailed.value = false;
|
||||
try {
|
||||
const failed = await executeWithProductionSqlGuard({
|
||||
connection: targetConnection,
|
||||
database: targetDatabase.value,
|
||||
database,
|
||||
sql: syncPlan.value.syncSql,
|
||||
source: t("production.sourceDataCompare"),
|
||||
execute: async () => {
|
||||
executing.value = true;
|
||||
syncErrors.value = [];
|
||||
executeTotal.value = syncPlan.value.syncStatements.length;
|
||||
executeTotal.value = statements.length;
|
||||
executedCount.value = 0;
|
||||
await store.ensureConnected(targetConnectionId.value);
|
||||
const statements = syncPlan.value.syncStatements;
|
||||
await store.ensureConnected(connectionId);
|
||||
if (useTransaction && (componentUnmounted || executionInterrupted)) return undefined;
|
||||
if (useTransaction) {
|
||||
txnSessionId.value = await api.beginManualTransaction(connectionId, database, schema);
|
||||
commitUncertain = false;
|
||||
}
|
||||
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
|
||||
if (useTransaction && (componentUnmounted || executionInterrupted)) return undefined;
|
||||
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
|
||||
try {
|
||||
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
|
||||
if (useTransaction) await api.executeInManualTransaction(txnSessionId.value!, batch.join(";\n"), database, schema);
|
||||
else await api.executeBatch(connectionId, database, batch, schema);
|
||||
executedCount.value += batch.length;
|
||||
} catch (e: any) {
|
||||
// A failed manual transaction is rolled back by the backend. Never
|
||||
// replay its statements on an ordinary connection.
|
||||
if (useTransaction) throw e;
|
||||
for (const stmt of batch) {
|
||||
try {
|
||||
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
|
||||
await api.executeBatch(connectionId, database, [stmt], schema);
|
||||
} catch (singleError: any) {
|
||||
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
|
||||
}
|
||||
@@ -782,18 +813,76 @@ async function executeSql() {
|
||||
},
|
||||
});
|
||||
if (failed === undefined) return;
|
||||
if (failed === 0) {
|
||||
if (failed === 0 && !txnSessionId.value) {
|
||||
toast(t("dataCompare.syncSuccess"), 2000);
|
||||
} else {
|
||||
toast(t("diff.syncSummary", { success: syncPlan.value.syncStatements.length - failed, failed }), 5000);
|
||||
} else if (failed > 0) {
|
||||
toast(t("diff.syncSummary", { success: statements.length - failed, failed }), 5000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
transactionFailed.value = true;
|
||||
toast(e?.message || String(e), 5000);
|
||||
} finally {
|
||||
if (txnSessionId.value && (transactionFailed.value || componentUnmounted || executionInterrupted)) await finishTransaction(false);
|
||||
executing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishTransaction(commit: boolean): Promise<boolean> {
|
||||
const sessionId = txnSessionId.value;
|
||||
if (!sessionId || resolvingTransaction.value || (commit && (executing.value || transactionFailed.value))) return false;
|
||||
resolvingTransaction.value = true;
|
||||
try {
|
||||
let committed = false;
|
||||
let outcomeUnknown = false;
|
||||
try {
|
||||
if (commit) {
|
||||
await api.commitManualTransaction(sessionId);
|
||||
committed = true;
|
||||
} else await api.rollbackManualTransaction(sessionId);
|
||||
} catch (error) {
|
||||
if (!isManualTransactionSessionExpired(error) && formatError(error) !== "Transaction session not found") {
|
||||
if (commit) {
|
||||
commitUncertain = true;
|
||||
transactionFailed.value = true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
outcomeUnknown = commitUncertain;
|
||||
if (outcomeUnknown) toast(t("toolbar.commitOutcomeUnknown"), 5000);
|
||||
else if (commit) toast(t("dataCompare.transactionEnded"), 5000);
|
||||
}
|
||||
txnSessionId.value = undefined;
|
||||
if (committed || outcomeUnknown) {
|
||||
const session = getDataCompareSession(activeSessionId.value);
|
||||
if (session) {
|
||||
session.batchResults = [];
|
||||
session.syncPlan = emptyDataCompareSyncPlan();
|
||||
session.version++;
|
||||
}
|
||||
clearResult();
|
||||
if (committed) toast(t("dataCompare.syncSuccess"), 2000);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast(formatError(error), 5000);
|
||||
return false;
|
||||
} finally {
|
||||
resolvingTransaction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenChange(value: boolean) {
|
||||
if (!value && ((executing.value && manualTransaction.value) || resolvingTransaction.value)) return;
|
||||
if (!value && txnSessionId.value) {
|
||||
if (!window.confirm(t("dataCompare.rollbackBeforeClose")) || !(await finishTransaction(false))) return;
|
||||
}
|
||||
open.value = value;
|
||||
}
|
||||
|
||||
watch(canUseManualTransaction, (supported) => {
|
||||
if (!supported && !executionLocked.value) manualTransaction.value = false;
|
||||
});
|
||||
|
||||
function formatValue(value: DataCompareCellValue): string {
|
||||
if (value == null) return "NULL";
|
||||
if (typeof value === "string") return value;
|
||||
@@ -912,7 +1001,12 @@ watch(targetTable, () => {
|
||||
watch(
|
||||
[() => open.value, () => props.sessionId],
|
||||
async ([value, sessionId]) => {
|
||||
if (!value) return;
|
||||
if (!value) {
|
||||
executionInterrupted = true;
|
||||
if (!executing.value && txnSessionId.value) void finishTransaction(false);
|
||||
return;
|
||||
}
|
||||
if (executionLocked.value) return;
|
||||
clearResult();
|
||||
shownSessionError = "";
|
||||
const session = getDataCompareSession(sessionId);
|
||||
@@ -960,11 +1054,12 @@ watch(
|
||||
);
|
||||
onBeforeUnmount(() => {
|
||||
componentUnmounted = true;
|
||||
if (!executing.value && txnSessionId.value) void finishTransaction(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<Dialog :open="open" @update:open="handleOpenChange">
|
||||
<DialogContent class="sm:max-w-5xl max-h-[85vh] flex flex-col overflow-hidden" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
@@ -973,7 +1068,7 @@ onBeforeUnmount(() => {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto space-y-4 py-2">
|
||||
<fieldset :disabled="executionLocked" class="flex-1 min-h-0 min-w-0 overflow-auto space-y-4 py-2">
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
|
||||
<div class="space-y-2 rounded-lg border border-blue-500/35 bg-blue-500/5 p-3">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-blue-600 dark:text-blue-400">
|
||||
@@ -982,7 +1077,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<ConnectionTreeSelect
|
||||
v-model="sourceConnectionId"
|
||||
:disabled="comparing"
|
||||
:disabled="comparing || executionLocked"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
@@ -997,7 +1092,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="t('diff.selectDatabase')"
|
||||
:search-placeholder="t('diff.searchDatabase')"
|
||||
:empty-text="t('common.noResults')"
|
||||
:disabled="comparing || !sourceDatabases.length"
|
||||
:disabled="comparing || executionLocked || !sourceDatabases.length"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
@@ -1006,7 +1101,7 @@ onBeforeUnmount(() => {
|
||||
v-if="sourceSchemas.length"
|
||||
v-model="sourceSchema"
|
||||
:options="sourceSchemas"
|
||||
:disabled="comparing"
|
||||
:disabled="comparing || executionLocked"
|
||||
:placeholder="t('diff.selectSchema')"
|
||||
:search-placeholder="t('diff.searchSchema')"
|
||||
:empty-text="t('common.noResults')"
|
||||
@@ -1021,12 +1116,12 @@ onBeforeUnmount(() => {
|
||||
:tables="sourceTables"
|
||||
:title="t('dataCompare.sourceTables')"
|
||||
:empty-text="!sourceConnectionId || !sourceDatabase ? t('dataCompare.selectSourceTables') : t('dataCompare.noTables')"
|
||||
:disabled="comparing"
|
||||
:disabled="comparing || executionLocked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-6">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing" @click="swapSourceTarget">
|
||||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="t('diff.swap')" :disabled="comparing || executionLocked" @click="swapSourceTarget">
|
||||
<ArrowLeftRight class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1038,7 +1133,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<ConnectionTreeSelect
|
||||
v-model="targetConnectionId"
|
||||
:disabled="comparing"
|
||||
:disabled="comparing || executionLocked"
|
||||
:connections="sqlConnections"
|
||||
:layout="store.sidebarLayout"
|
||||
:placeholder="t('diff.selectConnection')"
|
||||
@@ -1053,7 +1148,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="t('diff.selectDatabase')"
|
||||
:search-placeholder="t('diff.searchDatabase')"
|
||||
:empty-text="t('common.noResults')"
|
||||
:disabled="comparing || !targetDatabases.length"
|
||||
:disabled="comparing || executionLocked || !targetDatabases.length"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
@@ -1062,7 +1157,7 @@ onBeforeUnmount(() => {
|
||||
v-if="targetSchemas.length"
|
||||
v-model="targetSchema"
|
||||
:options="targetSchemas"
|
||||
:disabled="comparing"
|
||||
:disabled="comparing || executionLocked"
|
||||
:placeholder="t('diff.selectSchema')"
|
||||
:search-placeholder="t('diff.searchSchema')"
|
||||
:empty-text="t('common.noResults')"
|
||||
@@ -1079,7 +1174,7 @@ onBeforeUnmount(() => {
|
||||
:placeholder="t('dataCompare.selectTable')"
|
||||
:search-placeholder="t('dataCompare.searchTable')"
|
||||
:empty-text="t('common.noResults')"
|
||||
:disabled="comparing"
|
||||
:disabled="comparing || executionLocked"
|
||||
trigger-variant="outline"
|
||||
trigger-class="h-8 w-full justify-between text-xs"
|
||||
content-class="w-[var(--reka-popover-trigger-width)]"
|
||||
@@ -1112,7 +1207,7 @@ onBeforeUnmount(() => {
|
||||
<span v-if="singleKeyColumnRow" class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium" :class="singleKeyColumnRow.manual ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'">
|
||||
{{ singleKeyColumnRow.manual ? t("dataCompare.keyColumnsStatusManual") : t("dataCompare.keyColumnsStatusAuto") }}
|
||||
</span>
|
||||
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
|
||||
<Button v-if="singleKeyColumnRow?.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(singleKeyColumnRow.table)">
|
||||
{{ t("dataCompare.keyColumnsResetAuto") }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1123,7 +1218,13 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Single table: pick match columns straight from the real database columns. -->
|
||||
<div v-else-if="singleKeyColumnRow" class="space-y-1">
|
||||
<CompareKeyColumnsSelect :model-value="singleKeyColumnRow.columns" :columns="columnsForTable(singleKeyColumnRow.table)" :loading="singleKeyColumnRow.loading" :disabled="comparing" @update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)" />
|
||||
<CompareKeyColumnsSelect
|
||||
:model-value="singleKeyColumnRow.columns"
|
||||
:columns="columnsForTable(singleKeyColumnRow.table)"
|
||||
:loading="singleKeyColumnRow.loading"
|
||||
:disabled="comparing || executionLocked"
|
||||
@update:model-value="(columns) => setTableKeyColumns(singleKeyColumnRow!.table, columns)"
|
||||
/>
|
||||
<div class="text-[11px]" data-key-column-hint :class="singleKeyColumnRow.error || singleKeyColumnRow.status === 'missing' ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ singleKeyColumnRow.hint }}
|
||||
</div>
|
||||
@@ -1149,10 +1250,10 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="expandedKeyColumnTable === row.table" class="space-y-1 border-t bg-muted/10 px-2 py-2">
|
||||
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
|
||||
<CompareKeyColumnsSelect :model-value="row.columns" :columns="columnsForTable(row.table)" :loading="row.loading" :disabled="comparing || executionLocked" @update:model-value="(columns) => setTableKeyColumns(row.table, columns)" />
|
||||
<div class="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span data-key-column-hint :class="row.error || (row.status === 'missing' && row.matched) ? 'text-destructive' : 'text-muted-foreground'">{{ row.hint }}</span>
|
||||
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing" @click="clearTableKeyColumnsOverride(row.table)">
|
||||
<Button v-if="row.manual" size="sm" variant="ghost" class="h-6 px-1.5 text-[11px]" :disabled="comparing || executionLocked" @click="clearTableKeyColumnsOverride(row.table)">
|
||||
{{ t("dataCompare.keyColumnsResetAuto") }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1341,10 +1442,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<DialogFooter v-if="!hasResults">
|
||||
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
|
||||
<span v-if="compareProgressLabel" class="text-xs text-muted-foreground self-center">{{ compareProgressLabel }}</span>
|
||||
<Button size="sm" :disabled="!canCompare || comparing" @click="startCompare">
|
||||
<Loader2 v-if="comparing" class="w-3.5 h-3.5 animate-spin mr-1" />
|
||||
@@ -1354,8 +1455,8 @@ onBeforeUnmount(() => {
|
||||
</DialogFooter>
|
||||
|
||||
<DialogFooter v-else class="flex items-center gap-2">
|
||||
<Button variant="outline" @click="open = false">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" size="sm" :disabled="comparing || executing || !canCompare" @click="startCompare">
|
||||
<Button variant="outline" :disabled="(executing && manualTransaction) || resolvingTransaction" @click="handleOpenChange(false)">{{ t("common.close") }}</Button>
|
||||
<Button variant="outline" size="sm" :disabled="comparing || executionLocked || !canCompare" @click="startCompare">
|
||||
<Loader2 v-if="comparing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<RotateCcw v-else class="w-3 h-3 mr-1" />
|
||||
{{ t("dataCompare.recompare") }}
|
||||
@@ -1378,7 +1479,16 @@ onBeforeUnmount(() => {
|
||||
}}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" :disabled="!syncPlan.syncSql.trim()" @click="copySql"> <Copy class="w-3 h-3 mr-1" /> {{ t("diff.copySql") }} </Button>
|
||||
<Button size="sm" :disabled="planningSync || executing || syncPlan.statementCount === 0" @click="executeSql">
|
||||
<template v-if="txnSessionId && !executing">
|
||||
<span class="text-xs text-muted-foreground">{{ t("dataCompare.pendingTransaction") }}</span>
|
||||
<Button variant="outline" size="sm" :disabled="resolvingTransaction" @click="finishTransaction(false)">{{ t("toolbar.rollback") }}</Button>
|
||||
<Button size="sm" :disabled="resolvingTransaction || transactionFailed" @click="finishTransaction(true)">{{ t("toolbar.commit") }}</Button>
|
||||
</template>
|
||||
<label v-else-if="canUseManualTransaction" class="flex items-center gap-2 text-xs" :title="t('dataCompare.manualTransactionHint')">
|
||||
<input v-model="manualTransaction" type="checkbox" :disabled="executionLocked" />
|
||||
{{ t("toolbar.manualTransaction") }}
|
||||
</label>
|
||||
<Button v-if="!txnSessionId" size="sm" :disabled="planningSync || comparing || executionLocked || syncPlan.statementCount === 0" @click="executeSql">
|
||||
<Loader2 v-if="executing" class="w-3 h-3 animate-spin mr-1" />
|
||||
<Play v-else class="w-3 h-3 mr-1" />
|
||||
{{ t("diff.executeSync") }}
|
||||
|
||||
+156
@@ -7,6 +7,7 @@ import DataCompareDialog from "@/components/diff/DataCompareDialog.vue";
|
||||
import type { DataCompareSession } from "@/composables/useDataCompareSession";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: vi.fn(),
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
listDatabases: vi.fn().mockResolvedValue([]),
|
||||
listSchemas: vi.fn().mockResolvedValue(["DBX_TEST", "REPORTING", "SYS"]),
|
||||
@@ -14,8 +15,14 @@ const mocks = vi.hoisted(() => ({
|
||||
getColumns: vi.fn().mockResolvedValue([{ name: "ID", data_type: "NUMBER", is_primary_key: true }]),
|
||||
buildDataCompareSyncPlan: vi.fn(),
|
||||
executeBatch: vi.fn(),
|
||||
beginManualTransaction: vi.fn().mockResolvedValue("compare-txn"),
|
||||
executeInManualTransaction: vi.fn().mockResolvedValue([]),
|
||||
commitManualTransaction: vi.fn().mockResolvedValue({}),
|
||||
rollbackManualTransaction: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
|
||||
const sessionMocks = vi.hoisted(() => ({
|
||||
session: null as DataCompareSession | null,
|
||||
}));
|
||||
@@ -60,6 +67,10 @@ vi.mock("@/lib/backend/api", () => ({
|
||||
getColumns: mocks.getColumns,
|
||||
buildDataCompareSyncPlan: mocks.buildDataCompareSyncPlan,
|
||||
executeBatch: mocks.executeBatch,
|
||||
beginManualTransaction: mocks.beginManualTransaction,
|
||||
executeInManualTransaction: mocks.executeInManualTransaction,
|
||||
commitManualTransaction: mocks.commitManualTransaction,
|
||||
rollbackManualTransaction: mocks.rollbackManualTransaction,
|
||||
}));
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
@@ -76,6 +87,7 @@ afterEach(() => {
|
||||
document.body.textContent = "";
|
||||
sessionMocks.session = null;
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function completedSession(): DataCompareSession {
|
||||
@@ -159,6 +171,150 @@ function mountSessionDialog(session: DataCompareSession): App {
|
||||
return app;
|
||||
}
|
||||
|
||||
function buttonFor(key: string): HTMLButtonElement {
|
||||
const button = [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === i18n.global.t(key));
|
||||
if (!button) throw new Error(`Missing button: ${key}`);
|
||||
return button;
|
||||
}
|
||||
|
||||
async function mountManualSession(session = completedSession()) {
|
||||
const app = mountSessionDialog(session);
|
||||
await flushAsyncSetup();
|
||||
const label = [...document.querySelectorAll("label")].find((label) => label.textContent?.includes(i18n.global.t("toolbar.manualTransaction")));
|
||||
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
expect(checkbox).toBeDefined();
|
||||
checkbox!.click();
|
||||
await nextTick();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("DataCompareDialog manual transactions", () => {
|
||||
it("keeps the target session pending until commit and locks target changes", async () => {
|
||||
const session = completedSession();
|
||||
await mountManualSession(session);
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
|
||||
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("oracle-jdbc-11g", "REPORTING", "DBX_TEST");
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("compare-txn", "INSERT 1;\nINSERT 2", "REPORTING", "DBX_TEST");
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
expect(buttonFor("dataCompare.recompare").disabled).toBe(true);
|
||||
expect([...document.querySelectorAll<HTMLButtonElement>("button.dbx-searchable-select-trigger")].every((button) => button.disabled)).toBe(true);
|
||||
buttonFor("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.commitManualTransaction).toHaveBeenCalledWith("compare-txn"));
|
||||
await flushAsyncSetup();
|
||||
expect(document.body.textContent).not.toContain(i18n.global.t("dataCompare.pendingTransaction"));
|
||||
expect(session.syncPlan.syncStatements).toEqual([]);
|
||||
expect(session.batchResults).toEqual([]);
|
||||
});
|
||||
|
||||
it("rolls back without committing", async () => {
|
||||
await mountManualSession();
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
|
||||
buttonFor("toolbar.rollback").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops on the first failed batch without ordinary retries or later batches", async () => {
|
||||
const session = completedSession();
|
||||
session.syncPlan.syncStatements = Array.from({ length: 501 }, (_, index) => `INSERT ${index}`);
|
||||
session.syncPlan.statementCount = 501;
|
||||
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("transaction statement rejected"));
|
||||
await mountManualSession(session);
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up a transaction created after the dialog unmounts", async () => {
|
||||
let resolveBegin!: (id: string) => void;
|
||||
mocks.beginManualTransaction.mockReturnValueOnce(new Promise((resolve) => (resolveBegin = resolve)));
|
||||
const app = await mountManualSession();
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalled());
|
||||
mountedApps.splice(mountedApps.indexOf(app), 1);
|
||||
app.unmount();
|
||||
resolveBegin("late-compare-txn");
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("late-compare-txn"));
|
||||
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a rollback control when commit fails", async () => {
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("commit unavailable"));
|
||||
await mountManualSession();
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
|
||||
buttonFor("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.commitManualTransaction).toHaveBeenCalled());
|
||||
await flushAsyncSetup();
|
||||
expect(buttonFor("toolbar.rollback").disabled).toBe(false);
|
||||
expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction"));
|
||||
});
|
||||
|
||||
it("waits for an active batch before rollback on unmount and skips later batches", async () => {
|
||||
const session = completedSession();
|
||||
session.syncPlan.syncStatements = Array.from({ length: 501 }, (_, index) => `INSERT ${index}`);
|
||||
session.syncPlan.statementCount = 501;
|
||||
let finish!: (rows: unknown[]) => void;
|
||||
mocks.executeInManualTransaction.mockReturnValueOnce(new Promise((resolve) => (finish = resolve)));
|
||||
const app = await mountManualSession(session);
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(mocks.executeInManualTransaction).toHaveBeenCalledOnce());
|
||||
mountedApps.splice(mountedApps.indexOf(app), 1);
|
||||
app.unmount();
|
||||
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
|
||||
finish([]);
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invalidates the sync plan after an unknown commit result without claiming success", async () => {
|
||||
const session = completedSession();
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("response lost"));
|
||||
await mountManualSession(session);
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
|
||||
buttonFor("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("response lost", 5000));
|
||||
expect(buttonFor("toolbar.commit").disabled).toBe(true);
|
||||
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
buttonFor("toolbar.rollback").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith(i18n.global.t("toolbar.commitOutcomeUnknown"), 5000));
|
||||
expect(session.syncPlan.syncStatements).toEqual([]);
|
||||
expect(session.batchResults).toEqual([]);
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith(i18n.global.t("dataCompare.syncSuccess"), 2000);
|
||||
});
|
||||
|
||||
it("disables commit when rollback after an execution error fails", async () => {
|
||||
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("statement failed"));
|
||||
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("rollback unavailable"));
|
||||
await mountManualSession();
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalled());
|
||||
await flushAsyncSetup();
|
||||
expect(buttonFor("toolbar.commit").disabled).toBe(true);
|
||||
expect(buttonFor("toolbar.rollback").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("requires rollback confirmation before closing a pending transaction", async () => {
|
||||
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
vi.stubGlobal("confirm", confirm);
|
||||
await mountManualSession();
|
||||
buttonFor("diff.executeSync").click();
|
||||
await vi.waitFor(() => expect(document.body.textContent).toContain(i18n.global.t("dataCompare.pendingTransaction")));
|
||||
buttonFor("common.close").click();
|
||||
await flushAsyncSetup();
|
||||
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
|
||||
buttonFor("common.close").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("compare-txn"));
|
||||
expect(confirm).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataCompareDialog source prefill", () => {
|
||||
it("keeps the Oracle source table after loading database and schema prefills", async () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -18,6 +18,8 @@ import { dedupeMultiDbExecutionTargets, multiDbExecutionTargetKey, type MultiDbE
|
||||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { targetAllowsEmptyDatabase, targetIsSingleDatabase, targetSupportsCatalog, targetSupportsSchema, targetUsesConnectionOnlyScope } from "@/lib/database/sqlExecutionTargetCapabilities";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
const open = defineModel<boolean>("open", { default: false });
|
||||
|
||||
@@ -31,6 +33,7 @@ const props = defineProps<{
|
||||
cancelTarget?: MultiDbExecutionAdapter["cancelTarget"];
|
||||
cancelPending?: MultiDbExecutionAdapter["cancelPending"];
|
||||
sourceOffset?: number;
|
||||
initialManualTransaction?: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -65,6 +68,10 @@ const pendingClose = ref(false);
|
||||
const unsavedPromptOpen = ref(false);
|
||||
const executionStarted = ref(false);
|
||||
const executionMode = ref<MultiDbExecutionMode>("serial");
|
||||
const manualTransaction = ref(false);
|
||||
const discardTransactionsOpen = ref(false);
|
||||
const closingTransactions = ref(false);
|
||||
const supportsManualTransaction = computed(() => isTauriRuntime() && supportsTransaction(props.databaseType));
|
||||
const currentTime = ref(Date.now());
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let validationRun = 0;
|
||||
@@ -103,7 +110,7 @@ const hasUnsavedChanges = computed(() => {
|
||||
}
|
||||
return manualSelectionTouched.value;
|
||||
});
|
||||
const canExecute = computed(() => !isExecuting.value && !!props.sql.trim() && allTargetsValid.value && invalidTargetCount.value === 0 && needsRecheckCount.value === 0);
|
||||
const canExecute = computed(() => !isExecuting.value && !execution.hasTransactions.value && !!props.sql.trim() && allTargetsValid.value && invalidTargetCount.value === 0 && needsRecheckCount.value === 0);
|
||||
const canSaveGroup = computed(() => !isExecuting.value && !!props.databaseType && selectedTargets.value.length > 0 && allTargetsValid.value);
|
||||
const progressCompleted = computed(() => (batch.value?.items ?? []).filter((item) => !["pending", "running"].includes(item.status)).length);
|
||||
const progressCounts = computed(() => {
|
||||
@@ -113,6 +120,7 @@ const progressCounts = computed(() => {
|
||||
failed: items.filter((item) => item.status === "failed").length,
|
||||
skipped: items.filter((item) => item.status === "skipped").length,
|
||||
notExecuted: items.filter((item) => item.status === "not_executed" || item.status === "cancelled").length,
|
||||
pendingCommit: items.filter((item) => item.status === "pending_commit").length,
|
||||
};
|
||||
});
|
||||
const elapsedMs = computed(() => {
|
||||
@@ -125,7 +133,7 @@ function syncBackgroundTask(current: NonNullable<typeof batch.value>): void {
|
||||
const items = current.items;
|
||||
const completed = items.filter((item) => !["pending", "running"].includes(item.status)).length;
|
||||
const failureCount = items.filter((item) => item.status === "failed").length;
|
||||
const status = current.status === "cancelled" ? "cancelled" : current.status === "completed" ? "completed" : "running";
|
||||
const status = items.some((item) => item.transaction) ? "running" : current.status === "cancelled" ? "cancelled" : current.status === "completed" ? "completed" : "running";
|
||||
updateMultiDbExecutionTask(current.id, {
|
||||
sourceTabId: current.sourceTabId,
|
||||
total: items.length,
|
||||
@@ -178,6 +186,9 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
if (elapsedTimer) clearInterval(elapsedTimer);
|
||||
if (trackedBatchId) unregisterTaskCancelHandler(trackedBatchId);
|
||||
void execution.dispose().then((cleaned) => {
|
||||
if (!cleaned) toast(t("multiDbExecute.cleanupFailed"), 5000);
|
||||
});
|
||||
});
|
||||
|
||||
const searchQuery = computed(() => searchText.value.trim().toLocaleLowerCase());
|
||||
@@ -221,6 +232,8 @@ function statusLabel(status: MultiDbExecutionItemStatus): string {
|
||||
skipped: t("multiDbExecute.skipped"),
|
||||
cancelled: t("multiDbExecute.cancelled"),
|
||||
not_executed: t("multiDbExecute.notExecuted"),
|
||||
pending_commit: t("multiDbExecute.pendingCommit"),
|
||||
rolled_back: t("multiDbExecute.rolledBack"),
|
||||
};
|
||||
return labels[status];
|
||||
}
|
||||
@@ -557,6 +570,10 @@ function cancelUnsavedPrompt(): void {
|
||||
}
|
||||
|
||||
function requestClose(): void {
|
||||
if ((batch.value?.context.manualTransaction && isExecuting.value) || execution.hasTransactions.value) {
|
||||
discardTransactionsOpen.value = true;
|
||||
return;
|
||||
}
|
||||
if (isExecuting.value) {
|
||||
open.value = false;
|
||||
return;
|
||||
@@ -570,6 +587,21 @@ function requestClose(): void {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
async function discardTransactionsAndClose(): Promise<void> {
|
||||
if (closingTransactions.value) return;
|
||||
closingTransactions.value = true;
|
||||
try {
|
||||
if (!(await execution.cancelAndRollback())) {
|
||||
discardTransactionsOpen.value = false;
|
||||
return;
|
||||
}
|
||||
discardTransactionsOpen.value = false;
|
||||
open.value = false;
|
||||
} finally {
|
||||
closingTransactions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openGroupNameDialog(mode: "create" | "update" | "clone" | "rename", group?: SqlExecutionTargetGroup): void {
|
||||
if ((mode === "create" || mode === "update" || mode === "clone") && !canSaveGroup.value) return;
|
||||
groupNameMode.value = mode;
|
||||
@@ -637,9 +669,11 @@ function groupNameForId(id: string): string {
|
||||
}
|
||||
|
||||
function openForNewBatch(): void {
|
||||
if (isExecuting.value || execution.hasTransactions.value) return;
|
||||
execution.reset();
|
||||
executionStarted.value = false;
|
||||
executionMode.value = "serial";
|
||||
manualTransaction.value = props.initialManualTransaction === true && supportsManualTransaction.value;
|
||||
trackedBatchId = undefined;
|
||||
manageGroups.value = false;
|
||||
selectedGroupId.value = undefined;
|
||||
@@ -662,7 +696,7 @@ async function startExecution(): Promise<void> {
|
||||
if (!valid || !canExecute.value) return;
|
||||
if (selectedGroupId.value) targetGroupStore.markGroupUsed(selectedGroupId.value);
|
||||
executionStarted.value = true;
|
||||
void execution.start(props.sql, selectedTargets.value, { sourceOffset: props.sourceOffset }, executionMode.value);
|
||||
void execution.start(props.sql, selectedTargets.value, { sourceOffset: props.sourceOffset, manualTransaction: manualTransaction.value && supportsManualTransaction.value }, executionMode.value);
|
||||
}
|
||||
|
||||
function removeSelected(target: MultiDbExecutionTarget): void {
|
||||
@@ -722,7 +756,7 @@ watch(
|
||||
<DialogHeader class="shrink-0 border-b px-5 py-3">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Layers class="h-5 w-5 text-primary" />
|
||||
{{ executionStarted ? t("multiDbExecute.progress") : t("multiDbExecute.title") }}
|
||||
{{ executionStarted ? t("multiDbExecute.progress", { completed: progressCompleted, total: batch?.items.length ?? 0 }) : t("multiDbExecute.title") }}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -735,6 +769,7 @@ watch(
|
||||
<Badge variant="outline">{{ t("multiDbExecute.failed") }} {{ progressCounts.failed }}</Badge>
|
||||
<Badge variant="outline">{{ t("multiDbExecute.skipped") }} {{ progressCounts.skipped }}</Badge>
|
||||
<Badge variant="outline">{{ t("multiDbExecute.notExecuted") }} {{ progressCounts.notExecuted }}</Badge>
|
||||
<Badge v-if="progressCounts.pendingCommit" variant="outline">{{ t("multiDbExecute.pendingCommit") }} {{ progressCounts.pendingCommit }}</Badge>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div class="space-y-2">
|
||||
@@ -750,6 +785,10 @@ watch(
|
||||
<div v-if="item.durationMs !== undefined" class="text-xs tabular-nums text-muted-foreground">{{ t("multiDbExecute.elapsed", { duration: formatDataTransferDuration(item.durationMs) }) }}</div>
|
||||
<div v-if="item.errorMessage" class="mt-1 whitespace-pre-wrap break-words text-xs text-destructive">{{ item.errorMessage }}</div>
|
||||
</div>
|
||||
<div v-if="item.transaction" class="flex shrink-0 gap-2">
|
||||
<Button size="sm" :disabled="isExecuting || item.settling || !item.transaction.canCommit" @click="execution.finishTransaction(item.id, 'commit')">{{ t("multiDbExecute.commit") }}</Button>
|
||||
<Button size="sm" variant="outline" :disabled="isExecuting || item.settling" @click="execution.finishTransaction(item.id, 'rollback')">{{ t("multiDbExecute.rollback") }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -758,7 +797,7 @@ watch(
|
||||
<X class="h-4 w-4" />
|
||||
{{ t("multiDbExecute.cancelBatch") }}
|
||||
</Button>
|
||||
<Button v-if="isExecuting" variant="outline" @click="requestClose">{{ t("exportProgress.minimize") }}</Button>
|
||||
<Button v-if="isExecuting && !batch.context.manualTransaction" variant="outline" @click="requestClose">{{ t("exportProgress.minimize") }}</Button>
|
||||
<Button v-else @click="requestClose">{{ t("common.close") }}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
@@ -989,6 +1028,11 @@ watch(
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 border-t bg-muted/15 px-5 py-2 text-xs text-muted-foreground">
|
||||
<label v-if="supportsManualTransaction" class="flex items-center gap-2">
|
||||
<input v-model="manualTransaction" type="checkbox" :disabled="isExecuting" />
|
||||
{{ t("multiDbExecute.manualTransaction") }}
|
||||
</label>
|
||||
<p v-if="manualTransaction" class="w-full">{{ t("multiDbExecute.manualWarning") }}</p>
|
||||
<span>{{ executionMode === "serial" ? t("multiDbExecute.serialContinueOnError") : t("multiDbExecute.parallelContinueOnError") }}</span>
|
||||
<span v-if="validating" class="inline-flex items-center gap-1"><Loader2 class="h-3 w-3 animate-spin" />{{ t("common.loading") }}</span>
|
||||
<span v-else-if="invalidTargetCount" class="text-destructive">{{ t("multiDbExecute.invalidTargetCount", { count: invalidTargetCount }) }}</span>
|
||||
@@ -1013,6 +1057,19 @@ watch(
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="discardTransactionsOpen" @update:open="(value) => !closingTransactions && (discardTransactionsOpen = value)">
|
||||
<DialogContent class="sm:max-w-[480px]">
|
||||
<DialogHeader
|
||||
><DialogTitle>{{ t("multiDbExecute.discardTitle") }}</DialogTitle></DialogHeader
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">{{ t("multiDbExecute.discardWarning") }}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="closingTransactions" @click="discardTransactionsOpen = false">{{ t("dangerDialog.cancel") }}</Button>
|
||||
<Button variant="destructive" :disabled="closingTransactions || batch?.status === 'cancelling'" @click="discardTransactionsAndClose">{{ t("multiDbExecute.rollbackAndClose") }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog :open="groupNameDialogOpen" @update:open="(value) => (value ? (groupNameDialogOpen = true) : cancelGroupNameDialog())">
|
||||
<DialogContent class="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -20,6 +20,8 @@ import { historyEntrySource } from "@/lib/history/historyEntrySource";
|
||||
import type { HistoryConnectionFilter, HistoryDatabaseFilter, HistoryEntry, HistorySearchRequest } from "@/lib/backend/api";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
|
||||
import * as api from "@/lib/backend/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -54,6 +56,12 @@ const startDateInputRef = ref<HTMLInputElement | null>(null);
|
||||
const endDateInputRef = ref<HTMLInputElement | null>(null);
|
||||
const selectedEntry = ref<HistoryEntry | null>(null);
|
||||
const isRollingBack = ref(false);
|
||||
const manualRollback = ref(false);
|
||||
const resolvingTransaction = ref(false);
|
||||
const pendingRollback = ref<{ sessionId: string; entry: HistoryEntry; affectedRows: number; executionTime: number; ready: boolean; commitUncertain?: boolean }>();
|
||||
const rollbackLocked = computed(() => isRollingBack.value || !!pendingRollback.value || resolvingTransaction.value);
|
||||
let disposed = false;
|
||||
const canUseManualRollback = computed(() => supportsTransaction(connectionStore.getConfig(selectedEntry.value?.connection_id ?? "")?.db_type));
|
||||
const showDeleteConfirm = ref(false);
|
||||
const showClearConfirm = ref(false);
|
||||
const deleteTargetId = ref<string | null>(null);
|
||||
@@ -357,12 +365,30 @@ function detailsRows(entry: HistoryEntry) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function rollback(entry: HistoryEntry) {
|
||||
if (!canRollbackHistoryEntry(entry) || isRollingBack.value) return;
|
||||
async function recordRollback(entry: HistoryEntry, affectedRows: number, executionTime: number) {
|
||||
await store.add({
|
||||
connection_id: entry.connection_id,
|
||||
connection_name: entry.connection_name,
|
||||
database: entry.database,
|
||||
sql: entry.rollback_sql!,
|
||||
execution_time_ms: executionTime,
|
||||
success: true,
|
||||
activity_kind: "data_change",
|
||||
operation: "ROLLBACK",
|
||||
target: entry.target,
|
||||
affected_rows: affectedRows,
|
||||
details_json: JSON.stringify({ rollback_of: entry.id }),
|
||||
});
|
||||
}
|
||||
|
||||
async function rollback(entry: HistoryEntry, manual = false) {
|
||||
if (!canRollbackHistoryEntry(entry) || rollbackLocked.value) return;
|
||||
if (!window.confirm(t("history.rollbackConfirm"))) return;
|
||||
|
||||
entry = { ...entry };
|
||||
const connectionId = entry.connection_id!;
|
||||
const rollbackSql = entry.rollback_sql!;
|
||||
const useTransaction = manual && supportsTransaction(connectionStore.getConfig(connectionId)?.db_type);
|
||||
isRollingBack.value = true;
|
||||
const start = Date.now();
|
||||
try {
|
||||
@@ -371,31 +397,84 @@ async function rollback(entry: HistoryEntry) {
|
||||
database: entry.database,
|
||||
sql: rollbackSql,
|
||||
source: t("production.sourceQueryHistory"),
|
||||
execute: () => api.executeScript(connectionId, entry.database, rollbackSql),
|
||||
execute: async () => {
|
||||
if (!useTransaction) return api.executeScript(connectionId, entry.database, rollbackSql);
|
||||
if (disposed) return undefined;
|
||||
const sessionId = await api.beginManualTransaction(connectionId, entry.database);
|
||||
pendingRollback.value = { sessionId, entry, affectedRows: 0, executionTime: 0, ready: false };
|
||||
if (disposed) return undefined;
|
||||
const results = await api.executeInManualTransaction(sessionId, rollbackSql, entry.database);
|
||||
return { affected_rows: results.reduce((total, result) => total + result.affected_rows, 0) };
|
||||
},
|
||||
});
|
||||
if (!result) return;
|
||||
await store.add({
|
||||
connection_id: connectionId,
|
||||
connection_name: entry.connection_name,
|
||||
database: entry.database,
|
||||
sql: rollbackSql,
|
||||
execution_time_ms: Date.now() - start,
|
||||
success: true,
|
||||
activity_kind: "data_change",
|
||||
operation: "ROLLBACK",
|
||||
target: entry.target,
|
||||
affected_rows: result.affected_rows,
|
||||
details_json: JSON.stringify({ rollback_of: entry.id }),
|
||||
});
|
||||
toast(t("history.rollbackSuccess"));
|
||||
if (pendingRollback.value) {
|
||||
pendingRollback.value.affectedRows = result.affected_rows;
|
||||
pendingRollback.value.executionTime = Date.now() - start;
|
||||
pendingRollback.value.ready = true;
|
||||
} else {
|
||||
await recordRollback(entry, result.affected_rows, Date.now() - start);
|
||||
toast(t("history.rollbackSuccess"));
|
||||
}
|
||||
selectedEntry.value = null;
|
||||
} catch (e: any) {
|
||||
toast(t("history.rollbackFailed", { message: e?.message || String(e) }), 5000);
|
||||
} finally {
|
||||
if (pendingRollback.value && (!pendingRollback.value.ready || disposed)) await finishRollbackTransaction(false);
|
||||
isRollingBack.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishRollbackTransaction(commit: boolean): Promise<boolean> {
|
||||
const pending = pendingRollback.value;
|
||||
if (!pending || resolvingTransaction.value || (commit && (!pending.ready || isRollingBack.value))) return false;
|
||||
resolvingTransaction.value = true;
|
||||
try {
|
||||
let committed = false;
|
||||
try {
|
||||
if (commit) {
|
||||
await api.commitManualTransaction(pending.sessionId);
|
||||
committed = true;
|
||||
} else await api.rollbackManualTransaction(pending.sessionId);
|
||||
} catch (error) {
|
||||
if (!isManualTransactionSessionExpired(error) && formatError(error) !== "Transaction session not found") {
|
||||
if (commit) {
|
||||
pending.commitUncertain = true;
|
||||
pending.ready = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (pending.commitUncertain) toast(t("toolbar.commitOutcomeUnknown"), 5000);
|
||||
else if (commit) toast(t("history.transactionEnded"), 5000);
|
||||
}
|
||||
pendingRollback.value = undefined;
|
||||
if (committed) {
|
||||
toast(t("history.rollbackSuccess"));
|
||||
// A history-storage error cannot undo a database commit or leave a
|
||||
// transaction control that invites committing the same work again.
|
||||
try {
|
||||
await recordRollback(pending.entry, pending.affectedRows, pending.executionTime);
|
||||
} catch (error) {
|
||||
toast(formatError(error), 5000);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast(formatError(error), 5000);
|
||||
return false;
|
||||
} finally {
|
||||
resolvingTransaction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeHistory() {
|
||||
if (resolvingTransaction.value || (isRollingBack.value && pendingRollback.value)) return;
|
||||
if (pendingRollback.value) {
|
||||
if (!window.confirm(t("history.rollbackBeforeClose")) || !(await finishRollbackTransaction(false))) return;
|
||||
}
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function getHistoryMenuItems(entry: HistoryEntry): ContextMenuItem[] {
|
||||
return [
|
||||
{
|
||||
@@ -434,6 +513,8 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
if (pendingRollback.value && !isRollingBack.value) void finishRollbackTransaction(false);
|
||||
store.setHistoryPanelActive(false);
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = null;
|
||||
@@ -451,11 +532,17 @@ onBeforeUnmount(() => {
|
||||
<Button v-if="store.total > 0 || store.connectionOptions.length > 0" variant="ghost" size="icon" class="h-5 w-5" @click="confirmClearHistory">
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('close')">
|
||||
<Button variant="ghost" size="icon" class="h-5 w-5" :aria-label="t('common.close')" :disabled="resolvingTransaction || (isRollingBack && !!pendingRollback)" @click="closeHistory">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingRollback" class="flex flex-wrap items-center gap-2 border-b p-2 text-xs" role="status">
|
||||
<span class="w-full">{{ pendingRollback.entry.connection_name }} / {{ pendingRollback.entry.database }}: {{ t("history.pendingRollback") }}</span>
|
||||
<Button size="sm" variant="outline" :disabled="isRollingBack || resolvingTransaction" @click="finishRollbackTransaction(false)">{{ t("history.discardRollback") }}</Button>
|
||||
<Button size="sm" :disabled="isRollingBack || resolvingTransaction || !pendingRollback.ready" @click="finishRollbackTransaction(true)">{{ t("toolbar.commit") }}</Button>
|
||||
</div>
|
||||
|
||||
<div class="border-b shrink-0">
|
||||
<div ref="filterScrollRef" class="history-filter-scroll flex gap-1 px-2 pt-2" :class="{ 'history-filter-scroll--scrollable': filtersScrollable }">
|
||||
<button v-for="filter in filters" :key="filter" type="button" class="h-6 shrink-0 rounded border px-2 text-xs" :class="activeFilter === filter ? 'border-primary bg-primary text-primary-foreground' : 'bg-background'" @click="activeFilter = filter">
|
||||
@@ -683,7 +770,11 @@ onBeforeUnmount(() => {
|
||||
{{ t("history.analyzeWithAi") }}
|
||||
</Button>
|
||||
<Button variant="outline" @click="selectedEntry && restore(selectedEntry)">{{ t("history.restore") }}</Button>
|
||||
<Button v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)" :disabled="isRollingBack" @click="rollback(selectedEntry)">
|
||||
<label v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry) && canUseManualRollback" class="flex items-center gap-2 text-xs" :title="t('history.manualRollbackHint')">
|
||||
<input v-model="manualRollback" type="checkbox" :disabled="rollbackLocked" />
|
||||
{{ t("toolbar.manualTransaction") }}
|
||||
</label>
|
||||
<Button v-if="selectedEntry && canRollbackHistoryEntry(selectedEntry)" :disabled="rollbackLocked" @click="rollback(selectedEntry, manualRollback)">
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
{{ isRollingBack ? t("common.loading") : t("history.rollback") }}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { computed, createApp, nextTick, type App } from "vue";
|
||||
import MultiDbExecuteDialog from "../MultiDbExecuteDialog.vue";
|
||||
import type { MultiDbExecutionAdapter } from "@/composables/useMultiDbExecution";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/stores/sqlExecutionTargetGroupStore", () => ({ useSqlExecutionTargetGroupStore: () => ({ getGroupsByDatabaseType: () => [], getGroup: () => undefined }) }));
|
||||
vi.mock("@/composables/useExportTracker", () => ({ formatDataTransferDuration: String, useExportTracker: () => ({ addMultiDbExecutionTask: vi.fn(), updateMultiDbExecutionTask: vi.fn(), registerTaskCancelHandler: vi.fn(), unregisterTaskCancelHandler: vi.fn() }) }));
|
||||
vi.mock("@/composables/useMultiDbTargetSelection", () => ({
|
||||
useMultiDbTargetSelection: () => ({
|
||||
compatibleConnections: computed(() => []),
|
||||
connection: () => ({ id: "test", name: "Test", db_type: "oceanbase-oracle" }),
|
||||
loadConnection: vi.fn().mockResolvedValue(undefined),
|
||||
validateTarget: async () => ({ state: "valid" }),
|
||||
validateTargets: async (targets: unknown[]) => targets.map((target) => ({ target, state: "valid" })),
|
||||
}),
|
||||
}));
|
||||
|
||||
let app: App | undefined;
|
||||
const onOpenChange = vi.fn();
|
||||
afterEach(() => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
document.body.innerHTML = "";
|
||||
onOpenChange.mockClear();
|
||||
});
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
async function openDialog(executeTarget: MultiDbExecutionAdapter["executeTarget"]) {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(MultiDbExecuteDialog, { open: true, "onUpdate:open": onOpenChange, sql: "INSERT INTO t VALUES (1)", sourceTabId: "source", databaseType: "oceanbase-oracle", initialTargets: [{ connectionId: "test", database: "test" }], launchId: 1, initialManualTransaction: true, executeTarget });
|
||||
app.mount(root);
|
||||
await flushPromises();
|
||||
const checkbox = document.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
expect(checkbox?.checked).toBe(true);
|
||||
button("multiDbExecute.execute").click();
|
||||
await flushPromises();
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
const result = [...document.querySelectorAll<HTMLButtonElement>("button")].find((candidate) => candidate.textContent?.trim() === label);
|
||||
if (!result) throw new Error(`Missing button ${label}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("manual multi-database dialog", () => {
|
||||
it("shows pending changes, commits only on click, and clears transaction controls", async () => {
|
||||
const finish = vi.fn().mockResolvedValue(undefined);
|
||||
const execute = vi.fn<MultiDbExecutionAdapter["executeTarget"]>(async () => ({ status: "pending_commit" as const, transaction: { canCommit: true, finish } }));
|
||||
await openDialog(execute);
|
||||
expect(execute.mock.calls[0][0].context.manualTransaction).toBe(true);
|
||||
expect(document.body.textContent).toContain("multiDbExecute.pendingCommit");
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
button("multiDbExecute.commit").click();
|
||||
await flushPromises();
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("commit");
|
||||
expect([...document.querySelectorAll("button")].some((candidate) => candidate.textContent?.trim() === "multiDbExecute.commit")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the dialog open until pending changes are explicitly discarded", async () => {
|
||||
const finish = vi.fn().mockResolvedValue(undefined);
|
||||
await openDialog(vi.fn(async () => ({ status: "pending_commit" as const, transaction: { canCommit: true, finish } })));
|
||||
button("common.close").click();
|
||||
await flushPromises();
|
||||
expect(document.body.textContent).toContain("multiDbExecute.discardTitle");
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
button("multiDbExecute.rollbackAndClose").click();
|
||||
await flushPromises();
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("rollback");
|
||||
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false);
|
||||
});
|
||||
|
||||
it("keeps rollback available after failed cleanup", async () => {
|
||||
const finish = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(undefined);
|
||||
await openDialog(vi.fn(async () => ({ status: "failed" as const, transaction: { canCommit: false, finish } })));
|
||||
expect(button("multiDbExecute.commit").disabled).toBe(true);
|
||||
button("multiDbExecute.rollback").click();
|
||||
await flushPromises();
|
||||
expect(document.body.textContent).toContain("offline");
|
||||
expect(button("multiDbExecute.rollback").disabled).toBe(false);
|
||||
button("multiDbExecute.rollback").click();
|
||||
await flushPromises();
|
||||
expect(document.body.textContent).toContain("multiDbExecute.rolledBack");
|
||||
});
|
||||
});
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import QueryHistory from "../QueryHistory.vue";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
beginManualTransaction: vi.fn(),
|
||||
executeInManualTransaction: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
rollbackManualTransaction: vi.fn(),
|
||||
executeScript: vi.fn(),
|
||||
guard: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
close: vi.fn(),
|
||||
entry: {
|
||||
id: "history-1",
|
||||
connection_id: "ob-test",
|
||||
connection_name: "Transaction test",
|
||||
database: "SYS",
|
||||
sql: "UPDATE TEST_DATA SET VAL = 20 WHERE ID = 1",
|
||||
rollback_sql: "UPDATE TEST_DATA SET VAL = 10 WHERE ID = 1",
|
||||
execution_time_ms: 1,
|
||||
executed_at: "2026-01-01T00:00:00Z",
|
||||
success: true,
|
||||
activity_kind: "data_change",
|
||||
operation: "UPDATE",
|
||||
target: "TEST_DATA",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/stores/historyStore", () => ({
|
||||
useHistoryStore: () => ({
|
||||
entries: [mocks.entry],
|
||||
connectionOptions: [],
|
||||
total: 1,
|
||||
loading: false,
|
||||
error: "",
|
||||
nextCursor: null,
|
||||
setHistoryPanelActive: vi.fn(),
|
||||
loadConnectionOptions: vi.fn().mockResolvedValue(undefined),
|
||||
search: vi.fn().mockResolvedValue(undefined),
|
||||
add: mocks.add,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ getConfig: () => ({ id: "ob-test", db_type: "oceanbase-oracle" }) }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (text: string) => text }) }));
|
||||
vi.mock("@/lib/database/productionExecutionGuard", () => ({ executeWithProductionSqlGuard: mocks.guard }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
beginManualTransaction: mocks.beginManualTransaction,
|
||||
executeInManualTransaction: mocks.executeInManualTransaction,
|
||||
commitManualTransaction: mocks.commitManualTransaction,
|
||||
rollbackManualTransaction: mocks.rollbackManualTransaction,
|
||||
executeScript: mocks.executeScript,
|
||||
}));
|
||||
vi.mock("vue-virtual-scroller", () => ({
|
||||
RecycleScroller: defineComponent({
|
||||
props: ["items"],
|
||||
setup:
|
||||
(props, { slots }) =>
|
||||
() =>
|
||||
h(
|
||||
"div",
|
||||
props.items.map((item: unknown) => slots.default?.({ item })),
|
||||
),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/ui/CustomContextMenu.vue", () => ({
|
||||
default: defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.({ onContextMenu: vi.fn(), isOpen: false })),
|
||||
}),
|
||||
}));
|
||||
|
||||
let app: App | undefined;
|
||||
function button(key: string) {
|
||||
const found = [...document.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === key);
|
||||
if (!found) throw new Error(`Missing button ${key}`);
|
||||
return found;
|
||||
}
|
||||
async function openRollback(manual = true) {
|
||||
const root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(QueryHistory, { onClose: mocks.close });
|
||||
app.mount(root);
|
||||
await nextTick();
|
||||
const row = [...document.querySelectorAll<HTMLDivElement>("div")].find((item) => item.textContent?.includes(mocks.entry.sql) && item.classList.contains("cursor-pointer"));
|
||||
expect(row).toBeDefined();
|
||||
row!.click();
|
||||
await nextTick();
|
||||
await vi.waitFor(() => expect(button("history.rollback")).toBeDefined());
|
||||
if (manual) {
|
||||
const checkbox = [...document.querySelectorAll("label")].find((item) => item.textContent?.includes("toolbar.manualTransaction"))?.querySelector<HTMLInputElement>("input");
|
||||
expect(checkbox).toBeDefined();
|
||||
checkbox!.click();
|
||||
await nextTick();
|
||||
}
|
||||
button("history.rollback").click();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubGlobal("confirm", vi.fn().mockReturnValue(true));
|
||||
mocks.guard.mockImplementation((options) => options.execute());
|
||||
mocks.beginManualTransaction.mockResolvedValue("history-txn");
|
||||
mocks.executeInManualTransaction.mockResolvedValue([{ affected_rows: 1 }]);
|
||||
mocks.commitManualTransaction.mockResolvedValue({});
|
||||
mocks.rollbackManualTransaction.mockResolvedValue({});
|
||||
mocks.executeScript.mockResolvedValue({ affected_rows: 1 });
|
||||
mocks.add.mockResolvedValue(undefined);
|
||||
});
|
||||
afterEach(async () => {
|
||||
app?.unmount();
|
||||
app = undefined;
|
||||
await nextTick();
|
||||
document.body.textContent = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("QueryHistory manual rollback SQL", () => {
|
||||
it("uses the selected history target and records success only after commit", async () => {
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
|
||||
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("ob-test", "SYS");
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledWith("history-txn", mocks.entry.rollback_sql, "SYS");
|
||||
expect(mocks.executeScript).not.toHaveBeenCalled();
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).not.toHaveBeenCalledWith("history.rollbackSuccess");
|
||||
button("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.add).toHaveBeenCalledWith(expect.objectContaining({ sql: mocks.entry.rollback_sql, affected_rows: 1, details_json: JSON.stringify({ rollback_of: "history-1" }) })));
|
||||
expect(mocks.commitManualTransaction).toHaveBeenCalledWith("history-txn");
|
||||
});
|
||||
|
||||
it("discards the compensation without recording a successful rollback", async () => {
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(button("history.discardRollback").disabled).toBe(false));
|
||||
button("history.discardRollback").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("history-txn"));
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back a failed statement without falling back to ordinary execution", async () => {
|
||||
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("statement failed"));
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("history-txn"));
|
||||
expect(mocks.executeScript).not.toHaveBeenCalled();
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps transaction controls when commit fails and does not record success", async () => {
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("commit unavailable"));
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
|
||||
button("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("commit unavailable", 5000));
|
||||
expect(button("history.discardRollback").disabled).toBe(false);
|
||||
expect(button("toolbar.commit").disabled).toBe(true);
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not claim successful compensation after an unknown commit outcome", async () => {
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("response lost"));
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
|
||||
button("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("response lost", 5000));
|
||||
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
button("history.discardRollback").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("toolbar.commitOutcomeUnknown", 5000));
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
expect(mocks.commitManualTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up a session returned after unmount without executing compensation", async () => {
|
||||
let resolve!: (session: string) => void;
|
||||
mocks.beginManualTransaction.mockReturnValueOnce(new Promise((done) => (resolve = done)));
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalledOnce());
|
||||
app!.unmount();
|
||||
app = undefined;
|
||||
resolve("late-history-txn");
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("late-history-txn"));
|
||||
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a transaction when the production guard declines", async () => {
|
||||
mocks.guard.mockResolvedValueOnce(undefined);
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(mocks.guard).toHaveBeenCalledOnce());
|
||||
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
|
||||
expect(mocks.executeInManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not record success when the pending transaction has expired", async () => {
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
|
||||
button("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("history.transactionEnded", 5000));
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("history.pendingRollback");
|
||||
});
|
||||
|
||||
it("asks before discarding pending changes when closing history", async () => {
|
||||
await openRollback();
|
||||
await vi.waitFor(() => expect(button("toolbar.commit").disabled).toBe(false));
|
||||
const confirm = vi.mocked(window.confirm);
|
||||
confirm.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
const close = document.querySelector<HTMLButtonElement>('button[aria-label="common.close"]')!;
|
||||
close.click();
|
||||
await nextTick();
|
||||
expect(mocks.close).not.toHaveBeenCalled();
|
||||
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
|
||||
close.click();
|
||||
await vi.waitFor(() => expect(mocks.close).toHaveBeenCalledOnce());
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("history-txn");
|
||||
expect(mocks.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves ordinary execution when manual mode is not selected", async () => {
|
||||
await openRollback(false);
|
||||
await vi.waitFor(() => expect(mocks.add).toHaveBeenCalledOnce());
|
||||
expect(mocks.executeScript).toHaveBeenCalledWith("ob-test", "SYS", mocks.entry.rollback_sql);
|
||||
expect(mocks.beginManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,7 @@ const executeButtonClass = computed(() => {
|
||||
const canMultiExecute = computed(() => {
|
||||
if (!supportsQueryExecution(props.activeConnection?.db_type)) return false;
|
||||
if (props.activeTab.isExecuting || props.activeTab.isExplaining || props.activeTab.isCancelling) return false;
|
||||
if (props.autoCommit === false || isTransactionActive.value) return false;
|
||||
if (isTransactionActive.value) return false;
|
||||
return !!props.executableSql.trim();
|
||||
});
|
||||
|
||||
|
||||
+8
@@ -131,6 +131,14 @@ describe("EditorToolbar commit/rollback visibility", () => {
|
||||
const commitSelector = `button[aria-label="toolbar.commit"]`;
|
||||
const rollbackSelector = `button[aria-label="toolbar.rollback"]`;
|
||||
|
||||
it.each([undefined, "active-session"])("only opens independent multi-db transactions when the source has no active session (%s)", async (txnSessionId) => {
|
||||
const host = await mountToolbar({ dbType: "oceanbase-oracle", stickyProvenReadOnlyState: true, txnSessionId });
|
||||
const multiDb = host.querySelector<HTMLButtonElement>('button[aria-label="toolbar.multiDbExecute"]');
|
||||
expect(multiDb).not.toBeNull();
|
||||
expect(multiDb!.disabled).toBe(!!txnSessionId);
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("hides Commit/Rollback for a clean MySQL sticky manual session", async () => {
|
||||
const host = await mountToolbar({ dbType: "mysql", stickyProvenReadOnlyState: true, txnPossiblyDirty: false, txnSessionId: "txn-1" });
|
||||
|
||||
|
||||
+225
-4
@@ -5,6 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addSqlFileTask: vi.fn(),
|
||||
beginManualTransaction: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
rollbackManualTransaction: vi.fn(),
|
||||
cancelSqlFileExecution: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
executeSqlFiles: vi.fn(),
|
||||
@@ -14,6 +17,7 @@ const mocks = vi.hoisted(() => ({
|
||||
previewSqlFile: vi.fn(),
|
||||
inspectSqlFileTables: vi.fn(),
|
||||
progressHandler: undefined as undefined | ((progress: Record<string, unknown>) => void),
|
||||
changeDialogOpen: undefined as undefined | ((open: boolean) => void),
|
||||
refreshDatabaseTreeNode: vi.fn(),
|
||||
requestConfirmation: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
@@ -56,6 +60,9 @@ vi.mock("@/stores/connectionStore", () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
beginManualTransaction: mocks.beginManualTransaction,
|
||||
commitManualTransaction: mocks.commitManualTransaction,
|
||||
rollbackManualTransaction: mocks.rollbackManualTransaction,
|
||||
cancelSqlFileExecution: mocks.cancelSqlFileExecution,
|
||||
executeSqlFiles: mocks.executeSqlFiles,
|
||||
listenSqlFileProgress: mocks.listenSqlFileProgress,
|
||||
@@ -67,7 +74,13 @@ vi.mock("@lucide/vue", () => {
|
||||
return { Check: Icon, CheckSquare: Icon, ChevronRight: Icon, FileCode: Icon, FolderOpen: Icon, Loader2: Icon, Play: Icon, Square: Icon, X: Icon };
|
||||
});
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: passthrough("div"),
|
||||
Dialog: defineComponent({
|
||||
emits: ["update:open"],
|
||||
setup(_, { attrs, slots, emit }) {
|
||||
mocks.changeDialogOpen = (open) => emit("update:open", open);
|
||||
return () => h("div", attrs, slots.default?.());
|
||||
},
|
||||
}),
|
||||
DialogFooter: passthrough("div"),
|
||||
DialogHeader: passthrough("div"),
|
||||
DialogScrollContent: passthrough("div"),
|
||||
@@ -124,10 +137,10 @@ function findButton(label: string): HTMLButtonElement {
|
||||
return button!;
|
||||
}
|
||||
|
||||
async function mountReadyDialog() {
|
||||
async function mountReadyDialog(onOpenChange = vi.fn()) {
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true });
|
||||
app = createApp(SqlFileExecutionDialog, { open: true, "onUpdate:open": onOpenChange });
|
||||
app.mount(root);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.fetchSqlFileTargetOptions).toHaveBeenCalled());
|
||||
@@ -190,6 +203,9 @@ beforeEach(() => {
|
||||
});
|
||||
}
|
||||
});
|
||||
mocks.beginManualTransaction.mockReset().mockResolvedValue("txn-1");
|
||||
mocks.commitManualTransaction.mockReset().mockResolvedValue(undefined);
|
||||
mocks.rollbackManualTransaction.mockReset().mockResolvedValue(undefined);
|
||||
mocks.ensureConnected.mockResolvedValue(undefined);
|
||||
mocks.inspectSqlFileTables.mockResolvedValue([
|
||||
{ database: "app", name: "users" },
|
||||
@@ -212,10 +228,12 @@ beforeEach(() => {
|
||||
mocks.cancelSqlFileExecution.mockResolvedValue(true);
|
||||
mocks.refreshDatabaseTreeNode.mockResolvedValue(undefined);
|
||||
mocks.requestConfirmation.mockResolvedValue(true);
|
||||
mocks.uuid.mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
|
||||
mocks.uuid.mockReset().mockReturnValueOnce("run-1").mockReturnValueOnce("run-2");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
app?.unmount();
|
||||
root?.remove();
|
||||
app = undefined;
|
||||
@@ -223,6 +241,209 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SqlFileExecutionDialog retries", () => {
|
||||
it("allows an ordinary execution to continue in the background and close after completion", async () => {
|
||||
const onOpenChange = vi.fn();
|
||||
await mountReadyDialog(onOpenChange);
|
||||
const gate = deferred();
|
||||
mocks.executeSqlFiles.mockImplementationOnce(() => gate.promise);
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledOnce());
|
||||
findButton("sqlFile.runInBackground").click();
|
||||
await nextTick();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(mocks.cancelSqlFileExecution).not.toHaveBeenCalled();
|
||||
mocks.progressHandler?.(progress("run-1", "done"));
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
onOpenChange.mockClear();
|
||||
findButton("common.close").click();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("cancels the active ordinary run and allows a fresh retry", async () => {
|
||||
await mountReadyDialog();
|
||||
const gate = deferred();
|
||||
mocks.executeSqlFiles.mockImplementationOnce(() => gate.promise);
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledOnce());
|
||||
findButton("sqlFile.cancel").click();
|
||||
await vi.waitFor(() => expect(mocks.cancelSqlFileExecution).toHaveBeenCalledWith("run-1"));
|
||||
mocks.progressHandler?.(progress("run-1", "cancelled"));
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.cancelled"));
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
mocks.executeSqlFiles.mockImplementationOnce(async (request) => mocks.progressHandler?.(progress(request.executionId, "done")));
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.executeSqlFiles.mock.calls[1]![0].executionId).toBe("run-2");
|
||||
await vi.waitFor(() => expect(root!.textContent).toContain("sqlFile.status.done"));
|
||||
});
|
||||
|
||||
async function enableManualTransaction() {
|
||||
await mountReadyDialog();
|
||||
const label = Array.from(root!.querySelectorAll("label")).find((item) => item.textContent?.includes("toolbar.manualTransaction"));
|
||||
expect(label).toBeDefined();
|
||||
label!.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
|
||||
await nextTick();
|
||||
}
|
||||
|
||||
it("keeps successful files pending until an explicit commit and locks file selection", async () => {
|
||||
await enableManualTransaction();
|
||||
await completeFirstExecution();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
|
||||
expect(mocks.beginManualTransaction).toHaveBeenCalledWith("mysql-1", "");
|
||||
expect(mocks.executeSqlFiles.mock.calls[0]![0]).toMatchObject({ txnSessionId: "txn-1", continueOnError: false, skipRelationalConstraints: false });
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
|
||||
expect(mocks.refreshDatabaseTreeNode).not.toHaveBeenCalled();
|
||||
expect(findButton("sqlFile.browse").disabled).toBe(true);
|
||||
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "running", statementSummary: "sqlFile.pendingTransaction" }), expect.anything());
|
||||
findButton("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
expect(mocks.commitManualTransaction).toHaveBeenCalledExactlyOnceWith("txn-1");
|
||||
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "done" }));
|
||||
});
|
||||
|
||||
it("rolls back pending files without marking their task committed", async () => {
|
||||
await enableManualTransaction();
|
||||
await completeFirstExecution();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.rollback").disabled).toBe(false));
|
||||
const buttons = root!.querySelectorAll("button");
|
||||
expect(buttons[buttons.length - 1]).toBe(findButton("toolbar.rollback"));
|
||||
findButton("toolbar.rollback").click();
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
expect(mocks.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("txn-1");
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "cancelled" }));
|
||||
});
|
||||
|
||||
it("rolls back on execution failure without retrying the file", async () => {
|
||||
await enableManualTransaction();
|
||||
mocks.executeSqlFiles.mockRejectedValueOnce(new Error("statement failed"));
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
|
||||
expect(mocks.executeSqlFiles).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
expect(mocks.toast).toHaveBeenCalledWith("statement failed", 5000);
|
||||
});
|
||||
|
||||
it("waits for the active statement to finish before rolling back a cancellation", async () => {
|
||||
await enableManualTransaction();
|
||||
const gate = deferred();
|
||||
mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
|
||||
await gate.promise;
|
||||
mocks.progressHandler?.(progress(request.executionId, "cancelled"));
|
||||
});
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalled());
|
||||
findButton("sqlFile.cancel").click();
|
||||
await vi.waitFor(() => expect(mocks.cancelSqlFileExecution).toHaveBeenCalledWith("run-1"));
|
||||
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases a transaction that begins after the dialog unmounts", async () => {
|
||||
await enableManualTransaction();
|
||||
const gate = deferred();
|
||||
mocks.beginManualTransaction.mockImplementationOnce(async () => {
|
||||
await gate.promise;
|
||||
return "late-txn";
|
||||
});
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.beginManualTransaction).toHaveBeenCalled());
|
||||
app!.unmount();
|
||||
app = undefined;
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("late-txn"));
|
||||
expect(mocks.executeSqlFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "cancelled" }));
|
||||
});
|
||||
|
||||
it("rolls back before closing pending files and keeps them open when close is declined", async () => {
|
||||
await enableManualTransaction();
|
||||
await completeFirstExecution();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
|
||||
const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
vi.stubGlobal("confirm", confirm);
|
||||
mocks.changeDialogOpen!(false);
|
||||
await nextTick();
|
||||
expect(confirm).toHaveBeenCalledWith("sqlFile.rollbackBeforeClose");
|
||||
expect(mocks.rollbackManualTransaction).not.toHaveBeenCalled();
|
||||
mocks.changeDialogOpen!(false);
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("txn-1"));
|
||||
});
|
||||
|
||||
it("does not start file execution if cancelled while registering progress", async () => {
|
||||
await enableManualTransaction();
|
||||
const gate = deferred();
|
||||
mocks.listenSqlFileProgress.mockImplementationOnce(async () => {
|
||||
await gate.promise;
|
||||
return mocks.unlisten;
|
||||
});
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(mocks.listenSqlFileProgress).toHaveBeenCalled());
|
||||
findButton("sqlFile.cancel").click();
|
||||
gate.resolve();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
|
||||
expect(mocks.executeSqlFiles).not.toHaveBeenCalled();
|
||||
expect(mocks.unlisten).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not allow committing a failed file when cleanup could not be confirmed", async () => {
|
||||
await enableManualTransaction();
|
||||
mocks.executeSqlFiles.mockRejectedValueOnce(new Error("file failed"));
|
||||
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("rollback connection lost"));
|
||||
findButton("sqlFile.execute").click();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.rollback").disabled).toBe(false));
|
||||
expect(findButton("toolbar.commit").disabled).toBe(true);
|
||||
expect(findButton("sqlFile.browse").disabled).toBe(true);
|
||||
findButton("toolbar.rollback").click();
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
expect(mocks.commitManualTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains pending controls when commit fails, allowing rollback", async () => {
|
||||
await enableManualTransaction();
|
||||
await completeFirstExecution();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("connection lost"));
|
||||
findButton("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("connection lost", 5000));
|
||||
expect(findButton("sqlFile.browse").disabled).toBe(true);
|
||||
expect(findButton("toolbar.rollback").disabled).toBe(false);
|
||||
expect(findButton("toolbar.commit").disabled).toBe(true);
|
||||
findButton("toolbar.rollback").click();
|
||||
await vi.waitFor(() => expect(mocks.rollbackManualTransaction).toHaveBeenCalledWith("txn-1"));
|
||||
});
|
||||
|
||||
it("reports an expired transaction instead of claiming commit succeeded", async () => {
|
||||
await enableManualTransaction();
|
||||
await completeFirstExecution();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
findButton("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
|
||||
expect(mocks.toast).toHaveBeenCalledWith("sqlFile.transactionEnded", 5000);
|
||||
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "cancelled" }));
|
||||
});
|
||||
|
||||
it("reports an unknown commit outcome when the response was lost and rollback finds no session", async () => {
|
||||
await enableManualTransaction();
|
||||
await completeFirstExecution();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(false));
|
||||
mocks.commitManualTransaction.mockRejectedValueOnce(new Error("response lost"));
|
||||
findButton("toolbar.commit").click();
|
||||
await vi.waitFor(() => expect(findButton("toolbar.commit").disabled).toBe(true));
|
||||
await vi.waitFor(() => expect(findButton("toolbar.rollback").disabled).toBe(false));
|
||||
mocks.rollbackManualTransaction.mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
findButton("toolbar.rollback").click();
|
||||
await vi.waitFor(() => expect(mocks.toast).toHaveBeenCalledWith("toolbar.commitOutcomeUnknown", 5000));
|
||||
expect(mocks.updateSqlFileTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "error" }));
|
||||
expect(mocks.commitManualTransaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows byte-based progress while SQL executes and only completes on a terminal event", async () => {
|
||||
await mountReadyDialog();
|
||||
const executionGate = deferred();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
@@ -17,9 +17,11 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
import { connectionIsEffectivelyReadOnly, ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import { formatError, isManualTransactionSessionExpired } from "@/lib/backend/errorUtils";
|
||||
import { fetchSqlFileTargetOptions } from "@/composables/useDatabaseOptions";
|
||||
import { requiresSqlFileTargetDatabaseSelection, supportsConnectionLevelDatabaseBootstrap } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
|
||||
import { beginManualTransaction, commitManualTransaction, rollbackManualTransaction, cancelSqlFileExecution, executeSqlFiles, inspectSqlFileTables, listenSqlFileProgress, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus, type SqlFileTable } from "@/lib/backend/api";
|
||||
import { buildDisplayFileNames, tooltipText as computeTooltipText } from "./sqlFilePreviewLabel";
|
||||
import { parseSqlFilePathInput } from "./sqlFilePathInput";
|
||||
import SqlFileProgressIndicator from "./SqlFileProgressIndicator.vue";
|
||||
@@ -81,7 +83,7 @@ const pathInput = ref("");
|
||||
watch(filePathDisplay, (value) => (pathInput.value = value), { immediate: true });
|
||||
|
||||
async function commitPathInput() {
|
||||
if (!isDesktopRuntime || running.value || selectingFile.value || loadingPreview.value) return;
|
||||
if (!isDesktopRuntime || executionLocked.value || selectingFile.value || loadingPreview.value) return;
|
||||
const paths = parseSqlFilePathInput(pathInput.value);
|
||||
if (paths.length === 0 || paths.join("; ") === filePathDisplay.value) {
|
||||
pathInput.value = filePathDisplay.value;
|
||||
@@ -114,6 +116,11 @@ const databaseOptions = ref<string[]>([]);
|
||||
const loadingDatabases = ref(false);
|
||||
const continueOnError = ref(false);
|
||||
const skipRelationalConstraints = ref(false);
|
||||
const manualTransaction = ref(false);
|
||||
const txnSessionId = ref<string>();
|
||||
const commitUncertain = ref(false);
|
||||
const resolvingTransaction = ref(false);
|
||||
let disposed = false;
|
||||
|
||||
const running = ref(false);
|
||||
const cancelling = ref(false);
|
||||
@@ -181,6 +188,64 @@ const supportsRelationalConstraintBypass = computed(() => {
|
||||
});
|
||||
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
const canUseManualTransaction = computed(() => supportsTransaction(selectedConnection.value?.db_type));
|
||||
const executionLocked = computed(() => running.value || resolvingTransaction.value || !!txnSessionId.value);
|
||||
|
||||
async function finishTransaction(commit: boolean) {
|
||||
const sessionId = txnSessionId.value;
|
||||
if (!sessionId || resolvingTransaction.value || (commit && (terminalStatus.value !== "done" || commitUncertain.value))) return false;
|
||||
resolvingTransaction.value = true;
|
||||
try {
|
||||
let committed = false;
|
||||
let outcomeUnknown = false;
|
||||
try {
|
||||
if (commit) {
|
||||
await commitManualTransaction(sessionId);
|
||||
committed = true;
|
||||
} else await rollbackManualTransaction(sessionId);
|
||||
} catch (error) {
|
||||
if (!isManualTransactionSessionExpired(error) && formatError(error) !== "Transaction session not found") {
|
||||
if (commit) commitUncertain.value = true;
|
||||
throw error;
|
||||
}
|
||||
outcomeUnknown = commitUncertain.value;
|
||||
if (outcomeUnknown) toast(t("toolbar.commitOutcomeUnknown"), 5000);
|
||||
else if (commit) toast(t("sqlFile.transactionEnded"), 5000);
|
||||
}
|
||||
txnSessionId.value = undefined;
|
||||
if (terminalStatus.value === "done") {
|
||||
terminalStatus.value = outcomeUnknown ? "error" : committed ? "done" : "cancelled";
|
||||
if (progress.value) updateSqlFileTask(executionId.value, { ...progress.value, status: terminalStatus.value });
|
||||
}
|
||||
await refreshTargetAfterImport();
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
toast(error?.message || String(error), 5000);
|
||||
return false;
|
||||
} finally {
|
||||
resolvingTransaction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(manualTransaction, (enabled) => {
|
||||
if (enabled) {
|
||||
continueOnError.value = false;
|
||||
skipRelationalConstraints.value = false;
|
||||
}
|
||||
});
|
||||
watch(canUseManualTransaction, (supported) => {
|
||||
if (!supported && !txnSessionId.value) manualTransaction.value = false;
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
if (running.value && manualTransaction.value) {
|
||||
cancelRequested.value = true;
|
||||
if (executionStarted.value) void cancelSqlFileExecution(executionId.value).catch(() => {});
|
||||
}
|
||||
// The running call releases the session in its finally block; do not race a
|
||||
// rollback with an active statement. A late begin response is handled below.
|
||||
if (!running.value && txnSessionId.value) void finishTransaction(false);
|
||||
});
|
||||
|
||||
const restoreSelectedTables = ref(false);
|
||||
const backupTables = ref<SqlFileTable[]>([]);
|
||||
@@ -233,7 +298,7 @@ watch([restoreSelectedTables, canSelectTables, () => previews.value[0]?.filePath
|
||||
|
||||
const canStart = computed(() => {
|
||||
const connection = selectedConnection.value;
|
||||
if (previews.value.length === 0 || !connection || running.value || loadingPreview.value || loadingDatabases.value) return false;
|
||||
if (previews.value.length === 0 || !connection || executionLocked.value || loadingPreview.value || loadingDatabases.value) return false;
|
||||
if (restoreSelectedTables.value && (loadingTables.value || tableScanError.value || selectedTables.value.length === 0)) return false;
|
||||
let hasDatabaseContext = false;
|
||||
const canExecuteWithoutSelectedDatabase = previews.value.every((item) => {
|
||||
@@ -303,6 +368,8 @@ function formatElapsed(ms: number) {
|
||||
}
|
||||
|
||||
function statusLabel(status: SqlFileStatus | "idle") {
|
||||
if (status === "error" && commitUncertain.value) return t("toolbar.commitOutcomeUnknown");
|
||||
if (status === "done" && txnSessionId.value) return t("sqlFile.pendingTransaction");
|
||||
return t(`sqlFile.status.${status}`);
|
||||
}
|
||||
|
||||
@@ -349,6 +416,7 @@ function resetState() {
|
||||
loadingDatabases.value = false;
|
||||
continueOnError.value = false;
|
||||
skipRelationalConstraints.value = false;
|
||||
manualTransaction.value = false;
|
||||
restoreSelectedTables.value = false;
|
||||
resetExecution();
|
||||
}
|
||||
@@ -399,6 +467,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
|
||||
}
|
||||
|
||||
async function loadPreviews(filesOrPaths: Array<string | File>) {
|
||||
if (executionLocked.value) return;
|
||||
loadingPreview.value = true;
|
||||
previews.value = [];
|
||||
resetExecution();
|
||||
@@ -416,7 +485,7 @@ async function loadPreviews(filesOrPaths: Array<string | File>) {
|
||||
}
|
||||
|
||||
async function selectFile() {
|
||||
if (running.value) return;
|
||||
if (executionLocked.value) return;
|
||||
if (!isTauriRuntime()) {
|
||||
fileInput.value?.click();
|
||||
return;
|
||||
@@ -443,7 +512,7 @@ async function handleFileInputChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = "";
|
||||
if (files.length === 0 || running.value) return;
|
||||
if (files.length === 0 || executionLocked.value) return;
|
||||
selectingFile.value = true;
|
||||
try {
|
||||
await loadPreviews(files);
|
||||
@@ -513,6 +582,7 @@ async function startExecution() {
|
||||
failureDetailsExpanded.value = false;
|
||||
const taskLabel = previews.value.length === 1 ? previews.value[0]!.fileName : `${previews.value[0]!.fileName} (+${previews.value.length - 1})`;
|
||||
activeExecutionTask.value = addSqlFileTask(batchId, taskLabel, filePathDisplay.value);
|
||||
let completedSuccessfully = false;
|
||||
|
||||
try {
|
||||
await store.ensureConnected(connectionId.value);
|
||||
@@ -521,13 +591,22 @@ async function startExecution() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (manualTransaction.value) {
|
||||
const sessionId = await beginManualTransaction(connectionId.value, database.value.trim());
|
||||
txnSessionId.value = sessionId;
|
||||
commitUncertain.value = false;
|
||||
if (disposed || cancelRequested.value) {
|
||||
terminalStatus.value = "cancelled";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let resolveTerminalProgress: (progress: SqlFileProgress) => void = () => {};
|
||||
let rejectTerminalProgress: (error: Error) => void = () => {};
|
||||
const terminalProgress = new Promise<SqlFileProgress>((resolve, reject) => {
|
||||
resolveTerminalProgress = resolve;
|
||||
rejectTerminalProgress = reject;
|
||||
});
|
||||
let completedSuccessfully = false;
|
||||
const unlisten = await listenProgress(
|
||||
batchId,
|
||||
(next) => {
|
||||
@@ -564,7 +643,8 @@ async function startExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
updateSqlFileTask(batchId, next, {
|
||||
const taskProgress = txnSessionId.value && next.status === "done" ? { ...next, status: "running" as const, statementSummary: t("sqlFile.pendingTransaction") } : next;
|
||||
updateSqlFileTask(batchId, taskProgress, {
|
||||
fileIndex: currentFileIndex.value >= 0 ? currentFileIndex.value : previews.value.length === 1 ? 0 : undefined,
|
||||
fileName: currentFileName.value || (previews.value.length === 1 ? (displayFileNames.value.get(previews.value[0]!.filePath) ?? previews.value[0]!.fileName) : undefined),
|
||||
});
|
||||
@@ -578,6 +658,10 @@ async function startExecution() {
|
||||
);
|
||||
|
||||
try {
|
||||
if (cancelRequested.value) {
|
||||
terminalStatus.value = "cancelled";
|
||||
return;
|
||||
}
|
||||
executionStarted.value = true;
|
||||
const executionPaths = previews.value.flatMap((item) => item.packageFilePaths ?? [item.filePath]);
|
||||
await executeSqlFiles(
|
||||
@@ -587,6 +671,7 @@ async function startExecution() {
|
||||
database: database.value.trim(),
|
||||
filePath: executionPaths[0]!,
|
||||
continueOnError: continueOnError.value,
|
||||
...(txnSessionId.value ? { txnSessionId: txnSessionId.value } : {}),
|
||||
...(restoreSelectedTables.value ? { selectedTables: selectedTables.value.map((table) => ({ ...table })) } : {}),
|
||||
partCooldownMs: previews.value.some((item) => item.packageFilePaths) ? 500 : 0,
|
||||
skipRelationalConstraints: skipRelationalConstraints.value,
|
||||
@@ -606,7 +691,7 @@ async function startExecution() {
|
||||
unlisten();
|
||||
}
|
||||
|
||||
if (completedSuccessfully) await refreshTargetAfterImport();
|
||||
if (completedSuccessfully && !txnSessionId.value) await refreshTargetAfterImport();
|
||||
} catch (e: any) {
|
||||
terminalStatus.value = cancelRequested.value ? "cancelled" : "error";
|
||||
terminalError.value = e?.message || String(e);
|
||||
@@ -626,6 +711,12 @@ async function startExecution() {
|
||||
toast(terminalError.value, 5000);
|
||||
}
|
||||
} finally {
|
||||
if (txnSessionId.value && (!completedSuccessfully || disposed || cancelRequested.value)) {
|
||||
await finishTransaction(false);
|
||||
}
|
||||
if (cancelRequested.value && !progress.value) {
|
||||
updateSqlFileTask(batchId, { executionId: batchId, status: "cancelled", statementIndex: 0, successCount: 0, failureCount: 0, affectedRows: 0, elapsedMs: 0, statementSummary: "" });
|
||||
}
|
||||
running.value = false;
|
||||
cancelling.value = false;
|
||||
executionStarted.value = false;
|
||||
@@ -649,7 +740,12 @@ async function cancelExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
async function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen && (resolvingTransaction.value || (running.value && manualTransaction.value))) return;
|
||||
if (!nextOpen && txnSessionId.value) {
|
||||
if (!window.confirm(t("sqlFile.rollbackBeforeClose"))) return;
|
||||
if (!(await finishTransaction(false))) return;
|
||||
}
|
||||
open.value = nextOpen;
|
||||
}
|
||||
|
||||
@@ -658,15 +754,19 @@ watch(connectionId, (id) => {
|
||||
});
|
||||
|
||||
watch(sqlConnections, () => {
|
||||
if (!open.value || running.value || selectedConnection.value) return;
|
||||
if (!open.value || executionLocked.value || selectedConnection.value) return;
|
||||
connectionId.value = resolveInitialConnectionId();
|
||||
});
|
||||
|
||||
watch(
|
||||
open,
|
||||
(value) => {
|
||||
if (!value) return;
|
||||
if (running.value) return;
|
||||
if (!value) {
|
||||
if (manualTransaction.value && running.value) void cancelExecution();
|
||||
else if (txnSessionId.value) void finishTransaction(false);
|
||||
return;
|
||||
}
|
||||
if (running.value || txnSessionId.value || resolvingTransaction.value) return;
|
||||
resetState();
|
||||
if (connectionId.value) {
|
||||
loadDatabasesForConnection(connectionId.value);
|
||||
@@ -700,9 +800,9 @@ watch(
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input ref="fileInput" type="file" accept=".sql,.sql.gz,.zip,text/sql,application/gzip,application/zip" multiple class="hidden" @change="handleFileInputChange" />
|
||||
<Input v-if="isDesktopRuntime" v-model="pathInput" :disabled="running" class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" @keydown.enter="commitPathInputOnEnter" @blur="commitPathInput" />
|
||||
<Input v-if="isDesktopRuntime" v-model="pathInput" :disabled="executionLocked" class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" @keydown.enter="commitPathInputOnEnter" @blur="commitPathInput" />
|
||||
<Input v-else :model-value="filePathDisplay" readonly class="h-8 text-xs font-mono" :placeholder="t('sqlFile.selectSqlFile')" />
|
||||
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="running || selectingFile" @click="selectFile">
|
||||
<Button variant="outline" size="sm" class="h-8 shrink-0" :disabled="executionLocked || selectingFile" @click="selectFile">
|
||||
<Loader2 v-if="selectingFile || loadingPreview" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<FolderOpen v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ t("sqlFile.browse") }}
|
||||
@@ -761,7 +861,7 @@ watch(
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("sqlFile.connection") }}</Label>
|
||||
<Select v-model="connectionId" :disabled="running">
|
||||
<Select v-model="connectionId" :disabled="executionLocked">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<div v-if="connectionId" class="flex items-center gap-1.5 min-w-0">
|
||||
<DatabaseIcon :db-type="connectionIconType(connectionId)" class="w-3.5 h-3.5 shrink-0" />
|
||||
@@ -783,7 +883,7 @@ watch(
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs">{{ t("sqlFile.database") }}</Label>
|
||||
<Select v-if="databaseOptions.length" v-model="database" :disabled="running || loadingDatabases">
|
||||
<Select v-if="databaseOptions.length" v-model="database" :disabled="executionLocked || loadingDatabases">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue :placeholder="t('sqlFile.selectDatabase')" />
|
||||
</SelectTrigger>
|
||||
@@ -792,7 +892,7 @@ watch(
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else class="relative">
|
||||
<Input v-model="database" class="h-8 text-xs" :disabled="running || loadingDatabases" :placeholder="t('sqlFile.databasePlaceholder')" />
|
||||
<Input v-model="database" class="h-8 text-xs" :disabled="executionLocked || loadingDatabases" :placeholder="t('sqlFile.databasePlaceholder')" />
|
||||
<Loader2 v-if="loadingDatabases" class="absolute right-2 top-2 w-3.5 h-3.5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -802,22 +902,22 @@ watch(
|
||||
<div v-if="canSelectTables" class="min-w-0 space-y-2.5" data-table-restore>
|
||||
<Label class="text-xs">{{ t("sqlFile.restoreScope") }}</Label>
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="false" :disabled="running" name="sql-file-restore-scope" />{{ t("sqlFile.restoreAll") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="true" :disabled="running" name="sql-file-restore-scope" />{{ t("sqlFile.restoreSelectedTables") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="false" :disabled="executionLocked" name="sql-file-restore-scope" />{{ t("sqlFile.restoreAll") }}</label>
|
||||
<label class="flex items-center gap-2"><input v-model="restoreSelectedTables" type="radio" :value="true" :disabled="executionLocked" name="sql-file-restore-scope" />{{ t("sqlFile.restoreSelectedTables") }}</label>
|
||||
</div>
|
||||
<template v-if="restoreSelectedTables">
|
||||
<p class="text-xs text-muted-foreground">{{ t("sqlFile.restoreTablesOnly") }}</p>
|
||||
<div v-if="loadingTables" class="flex items-center gap-2 text-xs" role="status"><Loader2 class="h-3.5 w-3.5 animate-spin" />{{ t("sqlFile.scanningTables") }}</div>
|
||||
<p v-else-if="tableScanError" class="break-words text-xs text-destructive" role="alert">{{ tableScanError }}</p>
|
||||
<template v-else>
|
||||
<Input v-model="tableSearch" :placeholder="t('sqlFile.searchBackupTables')" :aria-label="t('sqlFile.searchBackupTables')" :disabled="running" class="h-8 text-xs" />
|
||||
<Input v-model="tableSearch" :placeholder="t('sqlFile.searchBackupTables')" :aria-label="t('sqlFile.searchBackupTables')" :disabled="executionLocked" class="h-8 text-xs" />
|
||||
<div class="flex items-center justify-between gap-3 text-xs">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" :checked="allFilteredTablesSelected" :disabled="running || filteredBackupTables.length === 0" @change="toggleFilteredTables" />{{ t("sqlFile.selectVisibleTables") }}</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" :checked="allFilteredTablesSelected" :disabled="executionLocked || filteredBackupTables.length === 0" @change="toggleFilteredTables" />{{ t("sqlFile.selectVisibleTables") }}</label>
|
||||
<span>{{ t("sqlFile.selectedTableCount", { selected: selectedTables.length, total: backupTables.length }) }}</span>
|
||||
</div>
|
||||
<div class="max-h-44 overflow-y-auto border rounded-md p-2 text-xs">
|
||||
<label v-for="table in filteredBackupTables" :key="tableKey(table)" class="flex min-w-0 items-center gap-2 py-1">
|
||||
<input type="checkbox" :checked="selectedTableKeys.has(tableKey(table))" :disabled="running" @change="toggleTable(table)" />
|
||||
<input type="checkbox" :checked="selectedTableKeys.has(tableKey(table))" :disabled="executionLocked" @change="toggleTable(table)" />
|
||||
<span class="min-w-0 break-all">{{ tableLabel(table) }}</span>
|
||||
</label>
|
||||
<p v-if="filteredBackupTables.length === 0" class="text-muted-foreground">{{ t("sqlFile.noBackupTables") }}</p>
|
||||
@@ -831,12 +931,17 @@ watch(
|
||||
{{ t("sqlFile.options") }}
|
||||
</div>
|
||||
|
||||
<button type="button" class="flex items-center gap-2 text-xs text-left" :disabled="running" @click="continueOnError = !continueOnError">
|
||||
<label v-if="canUseManualTransaction" class="flex items-center gap-2 text-xs">
|
||||
<input v-model="manualTransaction" type="checkbox" :disabled="executionLocked" />
|
||||
{{ t("toolbar.manualTransaction") }}
|
||||
</label>
|
||||
<p v-if="manualTransaction" class="text-xs text-muted-foreground">{{ t("sqlFile.manualTransactionHint") }}</p>
|
||||
<button type="button" class="flex items-center gap-2 text-xs text-left" :disabled="executionLocked || manualTransaction" @click="continueOnError = !continueOnError">
|
||||
<CheckSquare v-if="continueOnError" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("sqlFile.continueOnError") }}
|
||||
</button>
|
||||
<button v-if="supportsRelationalConstraintBypass" type="button" class="flex items-center gap-2 text-xs text-left" :disabled="running" @click="skipRelationalConstraints = !skipRelationalConstraints">
|
||||
<button v-if="supportsRelationalConstraintBypass" type="button" class="flex items-center gap-2 text-xs text-left" :disabled="executionLocked || manualTransaction" @click="skipRelationalConstraints = !skipRelationalConstraints">
|
||||
<CheckSquare v-if="skipRelationalConstraints" class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
|
||||
{{ t("sqlFile.skipRelationalConstraints") }}
|
||||
@@ -968,17 +1073,24 @@ watch(
|
||||
|
||||
<DialogFooter class="shrink-0">
|
||||
<template v-if="running">
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
<p v-if="manualTransaction" class="mr-auto text-xs text-muted-foreground">{{ t("sqlFile.manualCancelHint") }}</p>
|
||||
<Button v-else variant="outline" size="sm" @click="handleOpenChange(false)">
|
||||
{{ t("sqlFile.runInBackground") }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" :disabled="cancelling" @click="cancelExecution">
|
||||
<Button variant="destructive" size="sm" class="w-32" :disabled="cancelling" @click="cancelExecution">
|
||||
<Loader2 v-if="cancelling" class="w-3.5 h-3.5 mr-1.5 animate-spin" />
|
||||
<X v-else class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ cancelling ? t("sqlFile.cancelling") : t("sqlFile.cancel") }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="txnSessionId">
|
||||
<p class="mr-auto text-xs text-muted-foreground">{{ t("sqlFile.pendingTransaction") }}</p>
|
||||
<!-- Preserve the whole cancel hit area when execution finishes. -->
|
||||
<Button size="sm" :disabled="resolvingTransaction || commitUncertain || terminalStatus !== 'done'" @click="finishTransaction(true)">{{ t("toolbar.commit") }}</Button>
|
||||
<Button variant="outline" size="sm" class="w-32" :disabled="resolvingTransaction" @click="finishTransaction(false)">{{ t("toolbar.rollback") }}</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button variant="outline" size="sm" @click="open = false">
|
||||
<Button variant="outline" size="sm" @click="handleOpenChange(false)">
|
||||
{{ t("common.close") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="!canStart" @click="startExecution">
|
||||
|
||||
@@ -9,6 +9,116 @@ const targets = [
|
||||
] as const;
|
||||
|
||||
describe("useMultiDbExecution", () => {
|
||||
it("retains separate transactions until each target is explicitly settled", async () => {
|
||||
const commits = targets.map(() => vi.fn().mockResolvedValue(undefined));
|
||||
const executor = useMultiDbExecution(
|
||||
{
|
||||
executeTarget: async ({ target, context }) => {
|
||||
expect(context.manualTransaction).toBe(true);
|
||||
return { status: "pending_commit", transaction: { canCommit: true, finish: commits[targets.findIndex((candidate) => candidate.connectionId === target.connectionId)] } };
|
||||
},
|
||||
},
|
||||
{ sourceTabId: "source" },
|
||||
);
|
||||
const batch = await executor.start("UPDATE t SET n = 1", targets, { manualTransaction: true });
|
||||
expect(executor.hasTransactions.value).toBe(true);
|
||||
expect(commits.every((commit) => commit.mock.calls.length === 0)).toBe(true);
|
||||
executor.reset();
|
||||
expect(executor.batch.value?.id).toBe(batch?.id);
|
||||
expect(await executor.start("SELECT 2", targets)).toBeUndefined();
|
||||
await executor.finishTransaction(batch!.items[0].id, "commit");
|
||||
await executor.finishTransaction(batch!.items[1].id, "rollback");
|
||||
expect(batch!.items.map((item) => item.status)).toEqual(["success", "rolled_back", "pending_commit"]);
|
||||
expect(commits[0]).toHaveBeenCalledWith("commit");
|
||||
expect(commits[1]).toHaveBeenCalledWith("rollback");
|
||||
expect(commits[2]).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves a failed settlement for rollback and does not run it twice concurrently", async () => {
|
||||
let reject!: (reason: Error) => void;
|
||||
const finish = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, fail) => {
|
||||
reject = fail;
|
||||
}),
|
||||
);
|
||||
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "pending_commit", transaction: { canCommit: true, finish } }) }, { sourceTabId: "source" });
|
||||
const batch = await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true });
|
||||
const committing = executor.finishTransaction(batch!.items[0].id, "commit");
|
||||
expect(await executor.finishTransaction(batch!.items[0].id, "commit")).toBe(false);
|
||||
reject(new Error("connection lost"));
|
||||
expect(await committing).toBe(false);
|
||||
expect(batch!.items[0].errorMessage).toBe("connection lost");
|
||||
expect(executor.hasTransactions.value).toBe(true);
|
||||
finish.mockResolvedValueOnce(undefined);
|
||||
expect(await executor.finishTransaction(batch!.items[0].id, "rollback")).toBe(true);
|
||||
expect(finish).toHaveBeenCalledTimes(2);
|
||||
expect(executor.hasTransactions.value).toBe(false);
|
||||
});
|
||||
|
||||
it("rolls back an execution that finishes after disposal without starting the next target", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const finish = vi.fn().mockResolvedValue(undefined);
|
||||
const executeTarget = vi.fn(async () => {
|
||||
await gate;
|
||||
return { status: "pending_commit" as const, transaction: { canCommit: true, finish } };
|
||||
});
|
||||
const executor = useMultiDbExecution({ executeTarget }, { sourceTabId: "source" });
|
||||
const run = executor.start("UPDATE t SET n = 1", targets, { manualTransaction: true });
|
||||
const disposing = executor.dispose();
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
release();
|
||||
await run;
|
||||
expect(await disposing).toBe(true);
|
||||
expect(executeTarget).toHaveBeenCalledTimes(1);
|
||||
expect(finish).toHaveBeenCalledExactlyOnceWith("rollback");
|
||||
expect(executor.batch.value!.items.map((item) => item.status)).toEqual(["rolled_back", "not_executed", "not_executed"]);
|
||||
});
|
||||
|
||||
it("waits for a commit in flight before disposing the other transactions", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const finish = vi.fn(async (action: string) => {
|
||||
if (action === "commit") await gate;
|
||||
});
|
||||
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "pending_commit", transaction: { canCommit: true, finish } }) }, { sourceTabId: "source" });
|
||||
const batch = await executor.start("UPDATE t SET n = 1", targets.slice(0, 2), { manualTransaction: true });
|
||||
const commit = executor.finishTransaction(batch!.items[0].id, "commit");
|
||||
const disposing = executor.dispose();
|
||||
release();
|
||||
await commit;
|
||||
expect(await disposing).toBe(true);
|
||||
expect(batch!.items.map((item) => item.status)).toEqual(["success", "rolled_back"]);
|
||||
expect(finish.mock.calls.map((call) => call[0])).toEqual(["commit", "rollback"]);
|
||||
});
|
||||
|
||||
it("retains failed cleanup without allowing commit or reset", async () => {
|
||||
const finish = vi.fn().mockRejectedValue(new Error("rollback unavailable"));
|
||||
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "failed", transaction: { canCommit: false, finish } }) }, { sourceTabId: "source" });
|
||||
const batch = await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true });
|
||||
expect(await executor.finishTransaction(batch!.items[0].id, "commit")).toBe(false);
|
||||
expect(finish).not.toHaveBeenCalled();
|
||||
expect(await executor.rollbackPending()).toBe(false);
|
||||
executor.reset();
|
||||
expect(executor.hasTransactions.value).toBe(true);
|
||||
expect(batch!.items[0].errorMessage).toBe("rollback unavailable");
|
||||
});
|
||||
|
||||
it("reports an unknown commit outcome instead of marking it rolled back", async () => {
|
||||
const finish = vi.fn().mockResolvedValue("Verify data: commit result unknown");
|
||||
const executor = useMultiDbExecution({ executeTarget: async () => ({ status: "pending_commit", transaction: { canCommit: false, finish } }) }, { sourceTabId: "source" });
|
||||
const batch = await executor.start("UPDATE t SET n = 1", [targets[0]], { manualTransaction: true });
|
||||
expect(await executor.finishTransaction(batch!.items[0].id, "rollback")).toBe(false);
|
||||
expect(batch!.items[0].status).toBe("failed");
|
||||
expect(batch!.items[0].errorMessage).toBe("Verify data: commit result unknown");
|
||||
expect(executor.hasTransactions.value).toBe(false);
|
||||
});
|
||||
|
||||
it("executes serially and continues after a target failure", async () => {
|
||||
const executionOrder: string[] = [];
|
||||
const executor = useMultiDbExecution(
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSqlExecution } from "../useSqlExecution";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useHistoryStore } from "@/stores/historyStore";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ createI18n: () => ({ global: { locale: { value: "en" }, setLocaleMessage: vi.fn() } }), useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
saveEditorSettings: vi.fn(),
|
||||
saveHistory: vi.fn(),
|
||||
unlockConnectionWrites: vi.fn(),
|
||||
lockConnectionWrites: vi.fn(),
|
||||
connectionWriteUnlockState: vi.fn().mockResolvedValue(0),
|
||||
beginManualTransaction: vi.fn(),
|
||||
executeInManualTransaction: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
rollbackManualTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
function setup() {
|
||||
const connection = { id: "target-connection", name: "Test", db_type: "oceanbase-oracle", host: "localhost", port: 2881, username: "test", password: "" } as ConnectionConfig;
|
||||
const tab = { id: "source", connectionId: "source-connection", database: "source_db", sql: "INSERT INTO t VALUES (1)", title: "SQL", mode: "query", isDirty: false, isExecuting: false, isCancelling: false, isExplaining: false } as QueryTab;
|
||||
const store = useQueryStore();
|
||||
store.tabs.push(tab);
|
||||
const ordinary = vi.spyOn(store, "executeTabSql");
|
||||
const cleanup = vi.spyOn(store, "removeMultiDbExecutionWorker").mockResolvedValue(undefined);
|
||||
const capture = vi.spyOn(store, "captureMultiDbExecutionWorkerResult").mockReturnValue("result-run");
|
||||
const history = vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
const execution = useSqlExecution({ activeTab: computed(() => tab), activeConnection: computed(() => connection), executableSql: computed(() => tab.sql), activeOutputView: ref("result"), requestDangerConfirmation: async () => true });
|
||||
const input = {
|
||||
tab,
|
||||
connection,
|
||||
sql: tab.sql,
|
||||
scopeId: "batch",
|
||||
executionTarget: { connectionId: connection.id, database: "target_db", schema: "target_schema" },
|
||||
resultRun: { batchId: "batch", title: "Target", target: { connectionId: connection.id, database: "target_db" } },
|
||||
manualTransaction: true,
|
||||
};
|
||||
return { execution, input, ordinary, cleanup, capture, history };
|
||||
}
|
||||
|
||||
describe("manual multi-database submission", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: vi.fn(), removeItem: vi.fn() });
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(api.beginManualTransaction).mockResolvedValue("session");
|
||||
vi.mocked(api.executeInManualTransaction).mockResolvedValue([{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }]);
|
||||
vi.mocked(api.commitManualTransaction).mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 });
|
||||
vi.mocked(api.rollbackManualTransaction).mockResolvedValue({ columns: [], rows: [], affected_rows: 0, execution_time_ms: 0 });
|
||||
});
|
||||
|
||||
it("uses the captured target and records success only after an explicit commit", async () => {
|
||||
const { execution, input, ordinary, cleanup, capture, history } = setup();
|
||||
input.tab.resultCountSql = "SELECT COUNT(*) FROM unrelated_source";
|
||||
input.tab.resultPageSql = "SELECT * FROM unrelated_source";
|
||||
const result = await execution.executeTargetSql(input);
|
||||
expect(api.beginManualTransaction).toHaveBeenCalledExactlyOnceWith("target-connection", "target_db", "target_schema", undefined);
|
||||
expect(api.executeInManualTransaction).toHaveBeenCalledExactlyOnceWith("session", input.sql, "target_db", "target_schema", 100000);
|
||||
expect(ordinary).not.toHaveBeenCalled();
|
||||
expect(result.status).toBe("pending_commit");
|
||||
expect(capture.mock.calls[0][3].status).toBe("pending_commit");
|
||||
const worker = useQueryStore().getExecutionTab(capture.mock.calls[0][1]);
|
||||
expect(worker?.resultCountSql).toBeUndefined();
|
||||
expect(worker?.resultPageSql).toBeUndefined();
|
||||
expect(worker?.resultBaseSql).toBe(input.sql);
|
||||
expect(input.tab.resultCountSql).toBe("SELECT COUNT(*) FROM unrelated_source");
|
||||
expect(history).not.toHaveBeenCalled();
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
await result.transaction!.finish("commit");
|
||||
expect(api.commitManualTransaction).toHaveBeenCalledExactlyOnceWith("session");
|
||||
expect(history).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ connection_id: "target-connection", database: "target_db", success: true }));
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rolls back pending changes without writing a successful history entry", async () => {
|
||||
const { execution, input, history, cleanup } = setup();
|
||||
const result = await execution.executeTargetSql(input);
|
||||
await result.transaction!.finish("rollback");
|
||||
expect(api.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("session");
|
||||
expect(api.commitManualTransaction).not.toHaveBeenCalled();
|
||||
expect(history).not.toHaveBeenCalled();
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not replay a failed statement or an expired session", async () => {
|
||||
const { execution, input, ordinary } = setup();
|
||||
vi.mocked(api.executeInManualTransaction).mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
vi.mocked(api.rollbackManualTransaction).mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
const result = await execution.executeTargetSql(input);
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.transaction).toBeUndefined();
|
||||
expect(api.beginManualTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(api.executeInManualTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(ordinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up a late session if cancellation arrives while begin is pending", async () => {
|
||||
const { execution, input } = setup();
|
||||
let cancelled = false;
|
||||
let release!: (session: string) => void;
|
||||
vi.mocked(api.beginManualTransaction).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const running = execution.executeTargetSql({ ...input, isCancellationRequested: () => cancelled });
|
||||
await vi.waitFor(() => expect(api.beginManualTransaction).toHaveBeenCalled());
|
||||
cancelled = true;
|
||||
release("late-session");
|
||||
const result = await running;
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(api.executeInManualTransaction).not.toHaveBeenCalled();
|
||||
expect(api.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("late-session");
|
||||
});
|
||||
|
||||
it("retains failed rollback for cleanup and disallows commit", async () => {
|
||||
const { execution, input, cleanup } = setup();
|
||||
vi.mocked(api.executeInManualTransaction).mockRejectedValueOnce(new Error("statement failed"));
|
||||
vi.mocked(api.rollbackManualTransaction).mockRejectedValueOnce(new Error("transport unavailable"));
|
||||
const result = await execution.executeTargetSql(input);
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.transaction?.canCommit).toBe(false);
|
||||
expect(cleanup).not.toHaveBeenCalled();
|
||||
await result.transaction!.finish("rollback");
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry an uncertain commit or report a success history entry", async () => {
|
||||
const { execution, input, history } = setup();
|
||||
const result = await execution.executeTargetSql(input);
|
||||
vi.mocked(api.commitManualTransaction).mockRejectedValueOnce(new Error("transport unavailable"));
|
||||
await expect(result.transaction!.finish("commit")).rejects.toThrow("transport unavailable");
|
||||
await expect(result.transaction!.finish("commit")).rejects.toThrow("multiDbExecute.manualCommitUnavailable");
|
||||
expect(api.commitManualTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(history).not.toHaveBeenCalled();
|
||||
await result.transaction!.finish("rollback");
|
||||
});
|
||||
|
||||
it("does not turn a completed commit into a pending transaction when history fails", async () => {
|
||||
const { execution, input, history, cleanup } = setup();
|
||||
history.mockRejectedValueOnce(new Error("history unavailable"));
|
||||
const result = await execution.executeTargetSql(input);
|
||||
await expect(result.transaction!.finish("commit")).resolves.toBeUndefined();
|
||||
await result.transaction!.finish("commit");
|
||||
expect(api.commitManualTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not claim rollback after a lost commit response and a missing session", async () => {
|
||||
const { execution, input, cleanup, history } = setup();
|
||||
const result = await execution.executeTargetSql(input);
|
||||
vi.mocked(api.commitManualTransaction).mockRejectedValueOnce(new Error("response lost"));
|
||||
await expect(result.transaction!.finish("commit")).rejects.toThrow("response lost");
|
||||
vi.mocked(api.rollbackManualTransaction).mockRejectedValueOnce(new Error("Transaction session not found"));
|
||||
expect(await result.transaction!.finish("rollback")).toBe("toolbar.commitOutcomeUnknown");
|
||||
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||
expect(history).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops later statements in the same target after cancellation", async () => {
|
||||
const { execution, input } = setup();
|
||||
let cancelled = false;
|
||||
vi.mocked(api.executeInManualTransaction).mockImplementationOnce(async () => {
|
||||
cancelled = true;
|
||||
return [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }];
|
||||
});
|
||||
const result = await execution.executeTargetSql({ ...input, sql: "INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);", isCancellationRequested: () => cancelled });
|
||||
expect(result.status).toBe("cancelled");
|
||||
expect(api.executeInManualTransaction).toHaveBeenCalledExactlyOnceWith("session", "INSERT INTO t VALUES (1)", "target_db", "target_schema", 100000);
|
||||
expect(api.rollbackManualTransaction).toHaveBeenCalledExactlyOnceWith("session");
|
||||
});
|
||||
|
||||
it("keeps an Oracle procedural block intact while submitting subsequent statements on the same session", async () => {
|
||||
const { execution, input } = setup();
|
||||
const block = "BEGIN INSERT INTO t VALUES (1); INSERT INTO t VALUES (2); END;";
|
||||
const result = await execution.executeTargetSql({ ...input, sql: `${block}\nINSERT INTO t VALUES (3);` });
|
||||
expect(result.status).toBe("pending_commit");
|
||||
expect(api.executeInManualTransaction.mock.calls.map((call) => [call[0], call[1]])).toEqual([
|
||||
["session", block],
|
||||
["session", "INSERT INTO t VALUES (3)"],
|
||||
]);
|
||||
await result.transaction!.finish("rollback");
|
||||
});
|
||||
|
||||
it("preserves each submitted statement and editor range for the result summary", async () => {
|
||||
const { execution, input, capture } = setup();
|
||||
const first = "INSERT INTO t VALUES (1)";
|
||||
const second = "INSERT INTO t VALUES (2)";
|
||||
const result = await execution.executeTargetSql({ ...input, sql: `${first};\n${second};`, sourceOffset: 10 });
|
||||
const worker = useQueryStore().getExecutionTab(capture.mock.calls[0][1]);
|
||||
expect(worker?.results).toEqual([
|
||||
expect.objectContaining({ statement_index: 0, sourceStatement: first, sourceFrom: 10, sourceTo: 10 + first.length }),
|
||||
expect.objectContaining({ statement_index: 1, sourceStatement: second, sourceFrom: 12 + first.length, sourceTo: 12 + first.length + second.length }),
|
||||
]);
|
||||
await result.transaction!.finish("rollback");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, reactive, ref, type ComputedRef, type Ref } from "vue";
|
||||
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult } from "@/types/sqlExecution";
|
||||
import type { MultiDbExecutionTarget, MultiDbExecutionItemStatus, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
|
||||
|
||||
export interface MultiDbExecutionItem {
|
||||
id: string;
|
||||
@@ -9,6 +9,8 @@ export interface MultiDbExecutionItem {
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
durationMs?: number;
|
||||
transaction?: MultiDbManualTransaction;
|
||||
settling?: boolean;
|
||||
}
|
||||
|
||||
export interface MultiDbExecutionBatch {
|
||||
@@ -39,9 +41,10 @@ export interface MultiDbExecutionContext {
|
||||
readonly targets: readonly MultiDbExecutionTarget[];
|
||||
/** Offset of the submitted SQL in the source editor, captured at confirmation time. */
|
||||
readonly sourceOffset?: number;
|
||||
readonly manualTransaction?: boolean;
|
||||
}
|
||||
|
||||
export type MultiDbExecutionContextOverrides = Pick<MultiDbExecutionContext, "sourceOffset">;
|
||||
export type MultiDbExecutionContextOverrides = Pick<MultiDbExecutionContext, "sourceOffset" | "manualTransaction">;
|
||||
|
||||
export interface MultiDbExecutionOptions {
|
||||
sourceTabId: Ref<string> | ComputedRef<string> | string;
|
||||
@@ -61,6 +64,46 @@ function normalizeError(error: unknown): string {
|
||||
export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: MultiDbExecutionOptions) {
|
||||
const batch = ref<MultiDbExecutionBatch>();
|
||||
const isRunning = computed(() => batch.value?.status === "running" || batch.value?.status === "cancelling");
|
||||
const hasTransactions = computed(() => batch.value?.items.some((item) => !!item.transaction) === true);
|
||||
let activeRun: Promise<void> | undefined;
|
||||
let disposed = false;
|
||||
const settlements = new Set<Promise<unknown>>();
|
||||
|
||||
async function finishTransaction(itemId: string, action: "commit" | "rollback"): Promise<boolean> {
|
||||
const item = batch.value?.items.find((candidate) => candidate.id === itemId);
|
||||
if (!item?.transaction || item.settling || (action === "commit" && (!item.transaction.canCommit || disposed || isRunning.value))) return false;
|
||||
item.settling = true;
|
||||
let settled!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
settled = resolve;
|
||||
});
|
||||
settlements.add(pending);
|
||||
try {
|
||||
const warning = await item.transaction.finish(action);
|
||||
item.transaction = undefined;
|
||||
if (warning) {
|
||||
item.status = "failed";
|
||||
item.errorMessage = warning;
|
||||
return false;
|
||||
}
|
||||
item.status = action === "commit" ? "success" : "rolled_back";
|
||||
item.errorMessage = undefined;
|
||||
return true;
|
||||
} catch (error) {
|
||||
item.errorMessage = normalizeError(error);
|
||||
return false;
|
||||
} finally {
|
||||
item.settling = false;
|
||||
settlements.delete(pending);
|
||||
settled();
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackPending(): Promise<boolean> {
|
||||
const items = batch.value?.items ?? [];
|
||||
const results = await Promise.all(items.filter((item) => item.transaction).map((item) => finishTransaction(item.id, "rollback")));
|
||||
return results.every(Boolean) && !hasTransactions.value;
|
||||
}
|
||||
|
||||
function sourceTabId(): string {
|
||||
return typeof options.sourceTabId === "string" ? options.sourceTabId : options.sourceTabId.value;
|
||||
@@ -115,6 +158,7 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
item.status = current.cancelRequested && result.status === "failed" ? "cancelled" : result.status;
|
||||
item.errorMessage = result.errorMessage;
|
||||
item.durationMs = result.durationMs;
|
||||
item.transaction = result.transaction;
|
||||
} catch (error) {
|
||||
// One target is intentionally isolated from the queue. Adapter errors
|
||||
// become target failures so later targets keep running.
|
||||
@@ -136,14 +180,17 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
}
|
||||
}
|
||||
|
||||
if (current.cancelRequested) markPendingNotExecuted();
|
||||
if (current.cancelRequested) {
|
||||
markPendingNotExecuted();
|
||||
await rollbackPending();
|
||||
}
|
||||
current.status = current.cancelRequested ? "cancelled" : "completed";
|
||||
current.completedAt = Date.now();
|
||||
current.durationMs = current.completedAt - current.startedAt;
|
||||
}
|
||||
|
||||
async function start(sql: string, targets: readonly MultiDbExecutionTarget[], context: MultiDbExecutionContextOverrides = {}, mode: MultiDbExecutionMode = "serial"): Promise<MultiDbExecutionBatch | undefined> {
|
||||
if (isRunning.value || !sql.trim() || targets.length === 0) return undefined;
|
||||
if (disposed || isRunning.value || hasTransactions.value || !sql.trim() || targets.length === 0) return undefined;
|
||||
const sourceId = sourceTabId();
|
||||
const id = executionId();
|
||||
const targetSnapshot = targets.map((target) => Object.freeze({ ...target }));
|
||||
@@ -169,7 +216,9 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
batch.value = current;
|
||||
await executeBatch(current);
|
||||
activeRun = executeBatch(current);
|
||||
await activeRun;
|
||||
activeRun = undefined;
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -201,13 +250,30 @@ export function useMultiDbExecution(adapter: MultiDbExecutionAdapter, options: M
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
if (isRunning.value) return;
|
||||
if (isRunning.value || hasTransactions.value) return;
|
||||
batch.value = undefined;
|
||||
}
|
||||
|
||||
async function cancelAndRollback(): Promise<boolean> {
|
||||
await cancel();
|
||||
await activeRun;
|
||||
await Promise.all(settlements);
|
||||
return rollbackPending();
|
||||
}
|
||||
|
||||
async function dispose(): Promise<boolean> {
|
||||
disposed = true;
|
||||
return cancelAndRollback();
|
||||
}
|
||||
|
||||
return {
|
||||
batch,
|
||||
isRunning,
|
||||
hasTransactions,
|
||||
finishTransaction,
|
||||
rollbackPending,
|
||||
dispose,
|
||||
cancelAndRollback,
|
||||
start,
|
||||
cancel,
|
||||
reset,
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
|
||||
import { supportsConnectionScopedQueryExecution } from "@/lib/database/databaseFeatureSupport";
|
||||
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { agentProtocolQueryResultMaxRows, effectiveQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
|
||||
import { supportsConnectionLevelSqlExecution } from "@/lib/connection/connectionLevelDatabaseBootstrap";
|
||||
import { classifySqlActivityKind } from "@/lib/history/historyActivityKind";
|
||||
import { sqlMetadataRefreshTarget } from "@/lib/sql/sqlMetadataRefresh";
|
||||
@@ -25,7 +28,7 @@ import { ensureReadOnlyWriteAccess } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
|
||||
import type { SqlExecutionDangerRequest } from "@/stores/sqlExecutionDangerStore";
|
||||
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
|
||||
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult } from "@/types/sqlExecution";
|
||||
import type { MultiDbExecutionTarget, MultiDbResultRunExecution, MultiDbTargetExecutionResult, MultiDbManualTransaction } from "@/types/sqlExecution";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
@@ -65,6 +68,7 @@ interface TargetSqlExecutionInput {
|
||||
scopeId?: string;
|
||||
isCancellationRequested?: () => boolean;
|
||||
targetContext?: SqlExecutionTargetContext;
|
||||
manualTransaction?: boolean;
|
||||
}
|
||||
|
||||
export function stripSqlComments(sql: string): string {
|
||||
@@ -548,6 +552,9 @@ export function useSqlExecution(deps: {
|
||||
|
||||
if (cancelRequested()) return finish({ status: "cancelled" });
|
||||
const cancelRequestCount = tab.cancelRequestCount ?? 0;
|
||||
if (input.manualTransaction && (!input.executionTarget || !supportsTransaction(effectiveDatabaseTypeForConnection(connection)))) {
|
||||
return finish({ status: "failed", errorMessage: t("multiDbExecute.manualUnsupported") });
|
||||
}
|
||||
const workerId = input.executionTarget ? queryStore.createMultiDbExecutionWorker(tab.id, input.executionTarget, input.scopeId ?? "") : undefined;
|
||||
if (input.executionTarget && !workerId) return finish({ status: "failed", errorMessage: t("multiDbExecute.targetMissingConnection") });
|
||||
const executionTabId = workerId ?? tab.id;
|
||||
@@ -563,46 +570,151 @@ export function useSqlExecution(deps: {
|
||||
errorMessage,
|
||||
});
|
||||
};
|
||||
let manualSessionId: string | undefined;
|
||||
let retainedWorker = false;
|
||||
let commitUncertain = false;
|
||||
let recordedRunId: string | undefined;
|
||||
let recordCommittedOutcome: (() => Promise<void>) | undefined;
|
||||
const rollbackManualSession = async (sessionId: string) => {
|
||||
try {
|
||||
await api.rollbackManualTransaction(sessionId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Core already removes sessions on statement errors and idle expiry.
|
||||
if (!/transaction session not found/i.test(String(error))) throw error;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const transaction: MultiDbManualTransaction = {
|
||||
canCommit: false,
|
||||
async finish(action) {
|
||||
if (!manualSessionId) return;
|
||||
if (action === "commit" && !transaction.canCommit) throw new Error(t("multiDbExecute.manualCommitUnavailable"));
|
||||
let warning: string | undefined;
|
||||
if (action === "commit") {
|
||||
transaction.canCommit = false;
|
||||
try {
|
||||
await api.commitManualTransaction(manualSessionId);
|
||||
} catch (error) {
|
||||
commitUncertain = true;
|
||||
throw error;
|
||||
}
|
||||
} else if (!(await rollbackManualSession(manualSessionId)) && commitUncertain) {
|
||||
warning = t("toolbar.commitOutcomeUnknown");
|
||||
}
|
||||
// A later history/metadata/worker cleanup failure must never retry a commit.
|
||||
manualSessionId = undefined;
|
||||
transaction.canCommit = false;
|
||||
const run = queryStore.tabs.find((candidate) => candidate.id === tab.id)?.resultRuns?.find((candidate) => candidate.id === recordedRunId);
|
||||
if (run?.multiDbExecution) {
|
||||
run.multiDbExecution.status = warning ? "failed" : action === "commit" ? "success" : "rolled_back";
|
||||
run.multiDbExecution.errorMessage = warning;
|
||||
}
|
||||
try {
|
||||
if (action === "commit") await recordCommittedOutcome?.();
|
||||
} catch (error) {
|
||||
toast(String(error), 5000);
|
||||
} finally {
|
||||
if (workerId) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId).catch((error) => toast(String(error), 5000));
|
||||
}
|
||||
return warning;
|
||||
},
|
||||
};
|
||||
const failedManualResult = async (status: "failed" | "cancelled", errorMessage?: string): Promise<MultiDbTargetExecutionResult> => {
|
||||
transaction.canCommit = false;
|
||||
if (manualSessionId) {
|
||||
try {
|
||||
await rollbackManualSession(manualSessionId);
|
||||
manualSessionId = undefined;
|
||||
} catch (error) {
|
||||
retainedWorker = true;
|
||||
return finish({ status, errorMessage: [errorMessage, String(error)].filter(Boolean).join("\n"), transaction });
|
||||
}
|
||||
}
|
||||
return finish({ status, errorMessage });
|
||||
};
|
||||
try {
|
||||
await queryStore.executeTabSql(executionTabId, sql, {
|
||||
resultBaseSql: sql,
|
||||
...(input.targetContext ? { targetContext: input.targetContext } : {}),
|
||||
...(sourceOffset !== undefined ? { sourceOffset } : {}),
|
||||
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
|
||||
});
|
||||
if (input.manualTransaction) {
|
||||
manualSessionId = await api.beginManualTransaction(executionTab.connectionId, executionTab.database, executionTab.schema, executionTab.catalog);
|
||||
if (cancelRequested()) return await failedManualResult("cancelled");
|
||||
const worker = queryStore.getExecutionTab(executionTabId);
|
||||
if (!worker) throw new Error(t("multiDbExecute.targetMissingConnection"));
|
||||
// Submit once on the dedicated session. No ordinary-connection fallback,
|
||||
// expired-session replay, or per-statement retry is allowed for this batch.
|
||||
const maxRows = agentProtocolQueryResultMaxRows(effectiveQueryResultMaxRows(settingsStore.editorSettings.queryResultMaxRowsEnabled, settingsStore.editorSettings.queryResultMaxRows));
|
||||
const databaseType = effectiveDatabaseTypeForConnection(connection);
|
||||
const compatibility = databaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(executionTab.connectionId, executionTab.database) : undefined;
|
||||
const statements = splitSqlStatementRanges(sql, databaseType, sqlStatementParameterOptionsForCompatibility(databaseType, compatibility));
|
||||
const results: NonNullable<QueryTab["results"]> = [];
|
||||
for (const [statementIndex, statement] of (statements.length ? statements : [{ sql, from: 0, to: sql.length }]).entries()) {
|
||||
if (cancelRequested()) return await failedManualResult("cancelled");
|
||||
const statementResults = await api.executeInManualTransaction(manualSessionId, statement.sql, executionTab.database, executionTab.schema, maxRows);
|
||||
results.push(
|
||||
...statementResults.map((result) => ({
|
||||
...result,
|
||||
statement_index: statementIndex,
|
||||
sourceStatement: statement.sql,
|
||||
...(sourceOffset === undefined ? {} : { sourceFrom: sourceOffset + statement.from, sourceTo: sourceOffset + statement.to }),
|
||||
})),
|
||||
);
|
||||
if (statementResults.some(isQueryExecutionErrorResult)) break;
|
||||
}
|
||||
worker.results = results;
|
||||
worker.activeResultIndex = 0;
|
||||
worker.result = results[0];
|
||||
worker.resultBaseSql = sql;
|
||||
worker.lastExecutedSql = sql;
|
||||
} else
|
||||
await queryStore.executeTabSql(executionTabId, sql, {
|
||||
resultBaseSql: sql,
|
||||
...(input.targetContext ? { targetContext: input.targetContext } : {}),
|
||||
...(sourceOffset !== undefined ? { sourceOffset } : {}),
|
||||
...(connection.db_type === "redis" ? { skipRedisSafetyCheck: !blockRedisCommands } : {}),
|
||||
});
|
||||
const latest = queryStore.getExecutionTab(executionTabId) ?? tab;
|
||||
if (cancelRequested() || tabCancelRequested(cancelRequestCount)) {
|
||||
return finish({ status: "cancelled" });
|
||||
return await failedManualResult("cancelled");
|
||||
}
|
||||
focusSqlServerDataResult(executionTabId, connection.db_type, latest);
|
||||
const failure = firstQueryExecutionError(latest);
|
||||
const errorMessage = failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? t("common.failed"))) : undefined;
|
||||
const success = !failure;
|
||||
const resultStatus = success ? "success" : "failed";
|
||||
captureWorkerResult(resultStatus, errorMessage);
|
||||
historyStore.add({
|
||||
connection_id: executionTab.connectionId,
|
||||
connection_name: connection.name || "",
|
||||
database: executionTab.database,
|
||||
sql,
|
||||
execution_time_ms: Date.now() - startedAt,
|
||||
success,
|
||||
error: errorMessage,
|
||||
activity_kind: classifySqlActivityKind(sql),
|
||||
operation: primarySqlOperation(sql),
|
||||
affected_rows: success ? latest.result?.affected_rows : undefined,
|
||||
});
|
||||
if (success) {
|
||||
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
|
||||
if (refreshTarget.scope === "connection") {
|
||||
connectionStore.invalidateMetadataCache(executionTab.connectionId);
|
||||
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId });
|
||||
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
|
||||
} else if (refreshTarget.scope === "database") {
|
||||
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema });
|
||||
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
|
||||
const resultStatus = success ? (input.manualTransaction ? "pending_commit" : "success") : "failed";
|
||||
recordedRunId = captureWorkerResult(resultStatus, errorMessage);
|
||||
const executionDuration = Date.now() - startedAt;
|
||||
const recordOutcome = async () => {
|
||||
await historyStore.add({
|
||||
connection_id: executionTab.connectionId,
|
||||
connection_name: connection.name || "",
|
||||
database: executionTab.database,
|
||||
sql,
|
||||
execution_time_ms: executionDuration,
|
||||
success,
|
||||
error: errorMessage,
|
||||
activity_kind: classifySqlActivityKind(sql),
|
||||
operation: primarySqlOperation(sql),
|
||||
affected_rows: success ? latest.result?.affected_rows : undefined,
|
||||
});
|
||||
if (success) {
|
||||
const refreshTarget = sqlMetadataRefreshTarget(sql, executionTab.schema);
|
||||
if (refreshTarget.scope === "connection") {
|
||||
connectionStore.invalidateMetadataCache(executionTab.connectionId);
|
||||
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId });
|
||||
await connectionStore.loadDatabases(executionTab.connectionId, { force: true });
|
||||
} else if (refreshTarget.scope === "database") {
|
||||
await invalidateObjectMetadataCache({ connectionId: executionTab.connectionId, database: executionTab.database, schema: refreshTarget.schema });
|
||||
await connectionStore.refreshObjectListTreeNode(executionTab.connectionId, executionTab.database, refreshTarget.schema);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (input.manualTransaction && success) {
|
||||
transaction.canCommit = true;
|
||||
retainedWorker = true;
|
||||
recordCommittedOutcome = recordOutcome;
|
||||
return finish({ status: "pending_commit", transaction });
|
||||
}
|
||||
await recordOutcome();
|
||||
if (input.manualTransaction) return await failedManualResult("failed", errorMessage);
|
||||
// 多库 worker 路径(workerId 存在)下不切主编辑器输出视图:并行 Promise.all
|
||||
// 会让多个 worker 几乎同时改这个共享 ref,导致主视图在 result/summary 间反复
|
||||
// 跳动(闪烁/竞态)。worker 结果已由 captureMultiDbExecutionWorkerResult 记录
|
||||
@@ -614,9 +726,9 @@ export function useSqlExecution(deps: {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
captureWorkerResult("failed", errorMessage);
|
||||
return finish({ status: "failed", errorMessage });
|
||||
return await failedManualResult("failed", errorMessage);
|
||||
} finally {
|
||||
if (workerId) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId);
|
||||
if (workerId && !retainedWorker) await queryStore.removeMultiDbExecutionWorker(workerId, input.scopeId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Cari parolunuzu daxil edin və yeni parol seçin",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "Təsdiqləmə nəticəsi məlum deyil və sessiya artıq mövcud deyil. SQL-i yenidən icra etməzdən əvvəl məlumatları yoxlayın.",
|
||||
moreActions: "Digər əməliyyatlar",
|
||||
newConnection: "Yeni əlaqə",
|
||||
newQuery: "Yeni sorğu",
|
||||
@@ -234,6 +235,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "Bir neçə verilənlər bazasında icra et",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "Əl ilə tranzaksiya",
|
||||
manualWarning: "Hər hədəf ayrıca tranzaksiya istifadə edir və ayrıca təsdiqlənir və ya geri qaytarılır. Verilənlər bazaları arasında təsdiqləmə atomik deyil. DDL və açıq tranzaksiya əmrləri müstəqil təsdiqlənə bilər. Ləğv zamanı geri qaytarmadan əvvəl aktiv əmrin bitməsi gözlənilir.",
|
||||
pendingCommit: "Təsdiq gözlənilir",
|
||||
rolledBack: "Geri qaytarıldı",
|
||||
commit: "Təsdiqlə",
|
||||
rollback: "Geri qaytar",
|
||||
manualUnsupported: "Bu hədəf əl ilə tranzaksiyaları dəstəkləmir.",
|
||||
manualCommitUnavailable: "Təsdiqləmə mümkün deyil; tranzaksiyanı geri qaytarın.",
|
||||
discardTitle: "Gözləyən dəyişikliklər geri qaytarılsın?",
|
||||
discardWarning: "Geri qaytarmadan əvvəl aktiv əmrlər tamamlanacaq. Artıq təsdiqlənmiş hədəflər geri qaytarıla bilməz.",
|
||||
rollbackAndClose: "Geri qaytar və bağla",
|
||||
cleanupFailed: "Bəzi tranzaksiyalar geri qaytarıla bilmədi.",
|
||||
title: "Bir neçə verilənlər bazasında icra",
|
||||
progress: "İcra gedişi ({completed}/{total})",
|
||||
sqlPreview: "SQL önbaxışı",
|
||||
|
||||
@@ -327,6 +327,7 @@ export default {
|
||||
changePasswordDescription: "Enter your current password and choose a new one",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "Commit result is unknown and the session no longer exists. Verify the data before running this SQL again.",
|
||||
moreActions: "More actions",
|
||||
newConnection: "New Connection",
|
||||
newQuery: "New Query",
|
||||
@@ -377,6 +378,18 @@ export default {
|
||||
multiDbExecute: "Multi-db execute",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "Manual transaction",
|
||||
manualWarning: "Each target has its own transaction. Commit and rollback apply to one target at a time; commits across databases are not atomic. DDL and explicit transaction commands may commit independently. Cancellation waits for the active statement before rollback.",
|
||||
pendingCommit: "Awaiting commit",
|
||||
rolledBack: "Rolled back",
|
||||
commit: "Commit",
|
||||
rollback: "Rollback",
|
||||
manualUnsupported: "This target does not support manual transactions.",
|
||||
manualCommitUnavailable: "Commit is unavailable; roll back this transaction.",
|
||||
discardTitle: "Roll back pending changes?",
|
||||
discardWarning: "Running statements will finish before rollback. Already committed targets cannot be undone.",
|
||||
rollbackAndClose: "Roll back and close",
|
||||
cleanupFailed: "Some transactions could not be rolled back.",
|
||||
title: "Multi-database execution",
|
||||
progress: "Execution progress ({completed}/{total})",
|
||||
sqlPreview: "SQL preview",
|
||||
@@ -5830,6 +5843,11 @@ export default {
|
||||
deleteTableConfirm: "Delete HBase table {table}? All rows will be permanently removed.",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "Rollback SQL is pending. Commit to apply it or discard these changes.",
|
||||
discardRollback: "Discard rollback changes",
|
||||
manualRollbackHint: "Rollback SQL waits for Commit. DDL may still commit implicitly.",
|
||||
transactionEnded: "The transaction has ended or was rolled back. No rollback success was recorded.",
|
||||
rollbackBeforeClose: "Discard the uncommitted rollback changes and close history?",
|
||||
title: "History",
|
||||
search: "Search history...",
|
||||
conversationSearch: "Search conversations...",
|
||||
@@ -6536,6 +6554,11 @@ export default {
|
||||
generateDataComingSoon: "Coming soon",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "Changes wait for Commit or Rollback. Errors roll back. DDL and COMMIT inside the file can still commit changes.",
|
||||
manualCancelHint: "Cancel waits for the current statement, then rolls back uncommitted changes.",
|
||||
pendingTransaction: "Awaiting Commit or Rollback",
|
||||
rollbackBeforeClose: "There are uncommitted changes. Roll back and close?",
|
||||
transactionEnded: "The transaction has ended or was rolled back. Run the file again if needed.",
|
||||
restoreScope: "Restore scope",
|
||||
restoreAll: "All contents",
|
||||
restoreSelectedTables: "Selected tables",
|
||||
@@ -6961,6 +6984,10 @@ export default {
|
||||
noTables: "No tables available",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "Changes wait for Commit or Rollback. An error rolls back the transaction.",
|
||||
pendingTransaction: "Awaiting Commit or Rollback",
|
||||
rollbackBeforeClose: "There are uncommitted changes. Roll back and close?",
|
||||
transactionEnded: "The transaction has ended or was rolled back. Compare again before retrying.",
|
||||
title: "Compare Data",
|
||||
selectTable: "Select table",
|
||||
searchTable: "Search tables...",
|
||||
|
||||
@@ -318,6 +318,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Ingresa tu contraseña actual y elige una nueva",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "El resultado del commit es desconocido y la sesión ya no existe. Compruebe los datos antes de volver a ejecutar el SQL.",
|
||||
moreActions: "Más acciones",
|
||||
newConnection: "Nueva conexión",
|
||||
newQuery: "Nueva consulta",
|
||||
@@ -368,6 +369,19 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "Ejecución multi-base de datos",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "Transacción manual",
|
||||
manualWarning:
|
||||
"Cada destino usa una transacción independiente y se confirma o revierte por separado. Las confirmaciones entre bases de datos no son atómicas. DDL y los comandos explícitos pueden confirmar por su cuenta. Al cancelar, se espera a que termine la sentencia activa antes de revertir.",
|
||||
pendingCommit: "Pendiente de confirmación",
|
||||
rolledBack: "Revertido",
|
||||
commit: "Confirmar",
|
||||
rollback: "Revertir",
|
||||
manualUnsupported: "Este destino no admite transacciones manuales.",
|
||||
manualCommitUnavailable: "No se puede confirmar; revierta esta transacción.",
|
||||
discardTitle: "¿Revertir los cambios pendientes?",
|
||||
discardWarning: "Las sentencias activas terminarán antes de revertir. Los destinos ya confirmados no se pueden deshacer.",
|
||||
rollbackAndClose: "Revertir y cerrar",
|
||||
cleanupFailed: "No se pudieron revertir algunas transacciones.",
|
||||
title: "Ejecución en varias bases de datos",
|
||||
progress: "Progreso de ejecución ({completed}/{total})",
|
||||
sqlPreview: "Vista previa de SQL",
|
||||
@@ -5529,6 +5543,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "¿Eliminar la tabla HBase {table}? Todas las filas se eliminarán permanentemente.",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "El SQL de reversión está pendiente. Confirma o descarta estos cambios.",
|
||||
discardRollback: "Descartar cambios de reversión",
|
||||
manualRollbackHint: "El SQL de reversión espera confirmación. DDL puede confirmar implícitamente.",
|
||||
transactionEnded: "La transacción terminó o se revirtió. No se registró una reversión correcta.",
|
||||
rollbackBeforeClose: "¿Descartar los cambios de reversión sin confirmar y cerrar el historial?",
|
||||
title: "Historial",
|
||||
search: "Buscar en historial...",
|
||||
conversationSearch: "Buscar conversaciones...",
|
||||
@@ -6150,6 +6169,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "Próximamente",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "Los cambios esperan confirmación o reversión. Los errores provocan una reversión. DDL y COMMIT dentro del archivo aún pueden confirmar cambios.",
|
||||
manualCancelHint: "Cancelar espera a que termine la sentencia actual y luego revierte los cambios sin confirmar.",
|
||||
pendingTransaction: "Pendiente de confirmar o revertir",
|
||||
rollbackBeforeClose: "Hay cambios sin confirmar. ¿Revertir y cerrar?",
|
||||
transactionEnded: "La transacción ha terminado o se ha revertido. Vuelve a ejecutar el archivo si es necesario.",
|
||||
restoreScope: "Alcance de la restauración",
|
||||
restoreAll: "Todo el contenido",
|
||||
restoreSelectedTables: "Tablas seleccionadas",
|
||||
@@ -6522,6 +6546,10 @@ export default withEnglishFallback({
|
||||
noTables: "No hay tablas disponibles",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "Los cambios esperan a Confirmar o Revertir. Un error revierte la transacción.",
|
||||
pendingTransaction: "Esperando confirmación o reversión",
|
||||
rollbackBeforeClose: "Hay cambios sin confirmar. ¿Revertir y cerrar?",
|
||||
transactionEnded: "La transacción ha terminado o se ha revertido. Compara de nuevo antes de reintentar.",
|
||||
title: "Comparar datos",
|
||||
selectTable: "Seleccionar tabla",
|
||||
searchTable: "Buscar tablas...",
|
||||
|
||||
@@ -317,6 +317,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Inserisci la password attuale e scegline una nuova",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "L'esito del commit è sconosciuto e la sessione non esiste più. Verificare i dati prima di eseguire nuovamente SQL.",
|
||||
moreActions: "Altre azioni",
|
||||
newConnection: "Nuova Connessione",
|
||||
newQuery: "Nuova Query",
|
||||
@@ -367,6 +368,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "Esecuzione multi-DB",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "Transazione manuale",
|
||||
manualWarning: "Ogni destinazione usa una transazione indipendente, confermata o annullata separatamente. I commit tra database non sono atomici. DDL e comandi espliciti possono eseguire commit autonomi. La cancellazione attende la fine dell'istruzione attiva prima del rollback.",
|
||||
pendingCommit: "In attesa di commit",
|
||||
rolledBack: "Rollback eseguito",
|
||||
commit: "Commit",
|
||||
rollback: "Rollback",
|
||||
manualUnsupported: "Questa destinazione non supporta transazioni manuali.",
|
||||
manualCommitUnavailable: "Commit non disponibile; eseguire il rollback.",
|
||||
discardTitle: "Eseguire il rollback delle modifiche in sospeso?",
|
||||
discardWarning: "Le istruzioni attive termineranno prima del rollback. Le destinazioni già confermate non possono essere annullate.",
|
||||
rollbackAndClose: "Rollback e chiudi",
|
||||
cleanupFailed: "Rollback non riuscito per alcune transazioni.",
|
||||
title: "Esecuzione su più database",
|
||||
progress: "Avanzamento esecuzione ({completed}/{total})",
|
||||
sqlPreview: "Anteprima SQL",
|
||||
@@ -5527,6 +5540,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "Eliminare la tabella HBase {table}? Tutte le righe verranno eliminate definitivamente.",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "L’SQL di ripristino è in attesa. Conferma o scarta queste modifiche.",
|
||||
discardRollback: "Scarta modifiche di ripristino",
|
||||
manualRollbackHint: "L’SQL di ripristino attende conferma. DDL può confermare implicitamente.",
|
||||
transactionEnded: "La transazione è terminata o è stata annullata. Nessun ripristino riuscito è stato registrato.",
|
||||
rollbackBeforeClose: "Scartare le modifiche di ripristino non confermate e chiudere la cronologia?",
|
||||
title: "Cronologia",
|
||||
search: "Cerca nella cronologia...",
|
||||
conversationSearch: "Cerca conversazioni...",
|
||||
@@ -6148,6 +6166,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "Prossimamente",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "Le modifiche attendono commit o rollback. Gli errori causano il rollback. DDL e COMMIT nel file possono comunque confermare le modifiche.",
|
||||
manualCancelHint: "L'annullamento attende la fine dell'istruzione corrente, poi esegue il rollback delle modifiche non confermate.",
|
||||
pendingTransaction: "In attesa di commit o rollback",
|
||||
rollbackBeforeClose: "Ci sono modifiche non confermate. Eseguire il rollback e chiudere?",
|
||||
transactionEnded: "La transazione è terminata o è stata annullata. Esegui di nuovo il file se necessario.",
|
||||
restoreScope: "Ambito del ripristino",
|
||||
restoreAll: "Tutto il contenuto",
|
||||
restoreSelectedTables: "Tabelle selezionate",
|
||||
@@ -6522,6 +6545,10 @@ export default withEnglishFallback({
|
||||
noTables: "Nessuna tabella disponibile",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "Le modifiche attendono Conferma o Annulla. Un errore annulla la transazione.",
|
||||
pendingTransaction: "In attesa di conferma o annullamento",
|
||||
rollbackBeforeClose: "Ci sono modifiche non confermate. Annullare e chiudere?",
|
||||
transactionEnded: "La transazione è terminata o è stata annullata. Confronta di nuovo prima di riprovare.",
|
||||
title: "Confronta Dati",
|
||||
selectTable: "Seleziona tabella",
|
||||
searchTable: "Cerca tabelle...",
|
||||
|
||||
@@ -318,6 +318,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "現在のパスワードと新しいパスワードを入力してください",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "コミット結果は不明で、セッションは既に存在しません。SQL を再実行する前にデータを確認してください。",
|
||||
moreActions: "その他の操作",
|
||||
newConnection: "新しい接続",
|
||||
newQuery: "新しいクエリ",
|
||||
@@ -368,6 +369,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "複数DB実行",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "手動トランザクション",
|
||||
manualWarning: "各対象は独立したトランザクションを使用し、個別にコミットまたはロールバックします。データベース間のコミットはアトミックではありません。DDL や明示的なトランザクション文は独自にコミットする場合があります。キャンセル時は実行中の文が終了してからロールバックします。",
|
||||
pendingCommit: "コミット待ち",
|
||||
rolledBack: "ロールバック済み",
|
||||
commit: "コミット",
|
||||
rollback: "ロールバック",
|
||||
manualUnsupported: "この対象は手動トランザクションに対応していません。",
|
||||
manualCommitUnavailable: "コミットできません。ロールバックしてください。",
|
||||
discardTitle: "未コミットの変更をロールバックしますか?",
|
||||
discardWarning: "実行中の文が終了してからロールバックします。コミット済みの対象は元に戻せません。",
|
||||
rollbackAndClose: "ロールバックして閉じる",
|
||||
cleanupFailed: "一部のトランザクションをロールバックできませんでした。",
|
||||
title: "複数データベースで実行",
|
||||
progress: "実行の進行状況({completed}/{total})",
|
||||
sqlPreview: "SQLプレビュー",
|
||||
@@ -5556,6 +5569,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "HBase テーブル {table} を削除しますか?すべての行が完全に削除されます。",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "ロールバック SQL は保留中です。コミットするか今回の変更を破棄してください。",
|
||||
discardRollback: "今回のロールバックを破棄",
|
||||
manualRollbackHint: "ロールバック SQL はコミットを待ちます。DDL は暗黙的にコミットされる場合があります。",
|
||||
transactionEnded: "トランザクションは終了またはロールバックされました。ロールバック成功は記録されていません。",
|
||||
rollbackBeforeClose: "未コミットのロールバック変更を破棄して履歴を閉じますか?",
|
||||
title: "履歴",
|
||||
search: "履歴を検索...",
|
||||
conversationSearch: "会話を検索...",
|
||||
@@ -6186,6 +6204,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "近日公開",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "変更は手動でコミットまたはロールバックするまで保留されます。エラー時はロールバックします。ファイル内の DDL や COMMIT により変更が確定する場合があります。",
|
||||
manualCancelHint: "キャンセルすると現在の文の終了を待ち、未コミットの変更をロールバックします。",
|
||||
pendingTransaction: "コミットまたはロールバック待ち",
|
||||
rollbackBeforeClose: "未コミットの変更があります。ロールバックして閉じますか?",
|
||||
transactionEnded: "トランザクションは終了したか、ロールバックされました。必要に応じてファイルを再実行してください。",
|
||||
restoreScope: "復元範囲",
|
||||
restoreAll: "すべての内容",
|
||||
restoreSelectedTables: "指定したテーブル",
|
||||
@@ -6560,6 +6583,10 @@ export default withEnglishFallback({
|
||||
noTables: "利用可能なテーブルがありません",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "変更はコミットまたはロールバックを待ちます。エラー時はトランザクションをロールバックします。",
|
||||
pendingTransaction: "コミットまたはロールバック待ち",
|
||||
rollbackBeforeClose: "未コミットの変更があります。ロールバックして閉じますか?",
|
||||
transactionEnded: "トランザクションは終了またはロールバックされました。再実行する前に比較し直してください。",
|
||||
title: "データを比較",
|
||||
selectTable: "テーブルを選択",
|
||||
searchTable: "テーブルを検索...",
|
||||
|
||||
@@ -326,6 +326,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "현재 비밀번호를 입력하고 새 비밀번호를 선택하세요",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "커밋 결과를 알 수 없고 세션이 더 이상 존재하지 않습니다. SQL을 다시 실행하기 전에 데이터를 확인하세요。",
|
||||
moreActions: "더 많은 작업",
|
||||
newConnection: "새 연결",
|
||||
newQuery: "새 쿼리",
|
||||
@@ -375,6 +376,18 @@ export default withEnglishFallback({
|
||||
pluginCenter: "플러그인 센터",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "수동 트랜잭션",
|
||||
manualWarning: "각 대상은 독립된 트랜잭션으로 개별 커밋 또는 롤백합니다. 데이터베이스 간 커밋의 원자성은 보장되지 않습니다. DDL 및 명시적 트랜잭션 문은 자체 커밋할 수 있습니다. 취소하면 실행 중인 문이 끝난 후 롤백합니다.",
|
||||
pendingCommit: "커밋 대기",
|
||||
rolledBack: "롤백됨",
|
||||
commit: "커밋",
|
||||
rollback: "롤백",
|
||||
manualUnsupported: "이 대상은 수동 트랜잭션을 지원하지 않습니다.",
|
||||
manualCommitUnavailable: "커밋할 수 없습니다. 트랜잭션을 롤백하세요.",
|
||||
discardTitle: "미커밋 변경 사항을 롤백할까요?",
|
||||
discardWarning: "실행 중인 문이 끝난 후 롤백합니다. 이미 커밋한 대상은 되돌릴 수 없습니다.",
|
||||
rollbackAndClose: "롤백 후 닫기",
|
||||
cleanupFailed: "일부 트랜잭션을 롤백하지 못했습니다.",
|
||||
title: "다중 데이터베이스 실행",
|
||||
progress: "실행 진행률 ({completed}/{total})",
|
||||
sqlPreview: "SQL 미리보기",
|
||||
@@ -5164,6 +5177,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "HBase 테이블 {table}을(를) 삭제하시겠습니까? 모든 행이 영구적으로 제거됩니다.",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "롤백 SQL이 대기 중입니다. 커밋하거나 이번 변경 사항을 취소하세요.",
|
||||
discardRollback: "이번 롤백 변경 취소",
|
||||
manualRollbackHint: "롤백 SQL은 커밋을 기다립니다. DDL은 암시적으로 커밋될 수 있습니다.",
|
||||
transactionEnded: "트랜잭션이 종료되었거나 롤백되었습니다. 롤백 성공은 기록되지 않았습니다.",
|
||||
rollbackBeforeClose: "커밋되지 않은 롤백 변경 사항을 취소하고 기록을 닫을까요?",
|
||||
title: "기록",
|
||||
search: "기록 검색...",
|
||||
conversationSearch: "대화 검색...",
|
||||
@@ -5865,6 +5883,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "준비 중",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "변경 사항은 수동 커밋 또는 롤백을 기다립니다. 오류가 발생하면 롤백합니다. 파일의 DDL과 COMMIT은 변경 사항을 커밋할 수 있습니다.",
|
||||
manualCancelHint: "취소하면 현재 구문이 끝날 때까지 기다린 후 커밋되지 않은 변경 사항을 롤백합니다.",
|
||||
pendingTransaction: "커밋 또는 롤백 대기 중",
|
||||
rollbackBeforeClose: "커밋되지 않은 변경 사항이 있습니다. 롤백하고 닫을까요?",
|
||||
transactionEnded: "트랜잭션이 종료되었거나 롤백되었습니다. 필요하면 파일을 다시 실행하세요.",
|
||||
restoreScope: "복원 범위",
|
||||
restoreAll: "모든 내용",
|
||||
restoreSelectedTables: "선택한 테이블",
|
||||
@@ -6272,6 +6295,10 @@ export default withEnglishFallback({
|
||||
},
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "변경 사항은 커밋 또는 롤백을 기다립니다. 오류가 발생하면 트랜잭션을 롤백합니다.",
|
||||
pendingTransaction: "커밋 또는 롤백 대기 중",
|
||||
rollbackBeforeClose: "커밋되지 않은 변경 사항이 있습니다. 롤백하고 닫을까요?",
|
||||
transactionEnded: "트랜잭션이 종료되었거나 롤백되었습니다. 다시 실행하기 전에 비교하세요.",
|
||||
title: "데이터 비교",
|
||||
selectTable: "테이블 선택",
|
||||
searchTable: "테이블 검색...",
|
||||
|
||||
@@ -318,6 +318,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Digite sua senha atual e escolha uma nova",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "O resultado do commit é desconhecido e a sessão não existe mais. Verifique os dados antes de executar o SQL novamente.",
|
||||
moreActions: "Mais ações",
|
||||
newConnection: "Nova Conexão",
|
||||
newQuery: "Nova Consulta",
|
||||
@@ -368,6 +369,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "Execução em múltiplos bancos",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "Transação manual",
|
||||
manualWarning: "Cada destino usa uma transação independente, confirmada ou revertida separadamente. Os commits entre bancos não são atômicos. DDL e comandos explícitos podem confirmar por conta própria. O cancelamento aguarda o término da instrução ativa antes de reverter.",
|
||||
pendingCommit: "Aguardando commit",
|
||||
rolledBack: "Revertido",
|
||||
commit: "Commit",
|
||||
rollback: "Reverter",
|
||||
manualUnsupported: "Este destino não suporta transações manuais.",
|
||||
manualCommitUnavailable: "Não é possível confirmar; reverta esta transação.",
|
||||
discardTitle: "Reverter alterações pendentes?",
|
||||
discardWarning: "As instruções ativas terminarão antes da reversão. Destinos já confirmados não podem ser desfeitos.",
|
||||
rollbackAndClose: "Reverter e fechar",
|
||||
cleanupFailed: "Não foi possível reverter algumas transações.",
|
||||
title: "Execução em vários bancos de dados",
|
||||
progress: "Progresso da execução ({completed}/{total})",
|
||||
sqlPreview: "Prévia do SQL",
|
||||
@@ -5529,6 +5542,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "Excluir a tabela HBase {table}? Todas as linhas serão removidas permanentemente.",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "O SQL de reversão está pendente. Confirme ou descarte estas alterações.",
|
||||
discardRollback: "Descartar alterações da reversão",
|
||||
manualRollbackHint: "O SQL de reversão aguarda confirmação. DDL pode confirmar implicitamente.",
|
||||
transactionEnded: "A transação terminou ou foi revertida. Nenhuma reversão bem-sucedida foi registrada.",
|
||||
rollbackBeforeClose: "Descartar as alterações de reversão não confirmadas e fechar o histórico?",
|
||||
title: "Histórico",
|
||||
search: "Pesquisar histórico...",
|
||||
conversationSearch: "Pesquisar conversas...",
|
||||
@@ -6150,6 +6168,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "Em breve",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "As alterações aguardam confirmação ou reversão. Erros provocam reversão. DDL e COMMIT no arquivo ainda podem confirmar alterações.",
|
||||
manualCancelHint: "Cancelar aguarda o fim da instrução atual e depois reverte as alterações não confirmadas.",
|
||||
pendingTransaction: "Aguardando confirmação ou reversão",
|
||||
rollbackBeforeClose: "Há alterações não confirmadas. Reverter e fechar?",
|
||||
transactionEnded: "A transação terminou ou foi revertida. Execute o arquivo novamente se necessário.",
|
||||
restoreScope: "Escopo da restauração",
|
||||
restoreAll: "Todo o conteúdo",
|
||||
restoreSelectedTables: "Tabelas selecionadas",
|
||||
@@ -6524,6 +6547,10 @@ export default withEnglishFallback({
|
||||
noTables: "Nenhuma tabela disponível",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "As alterações aguardam confirmação ou reversão. Um erro reverte a transação.",
|
||||
pendingTransaction: "Aguardando confirmação ou reversão",
|
||||
rollbackBeforeClose: "Há alterações não confirmadas. Reverter e fechar?",
|
||||
transactionEnded: "A transação terminou ou foi revertida. Compare novamente antes de tentar de novo.",
|
||||
title: "Comparar Dados",
|
||||
selectTable: "Selecionar tabela",
|
||||
searchTable: "Pesquisar tabelas...",
|
||||
|
||||
@@ -187,6 +187,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "Mevcut parolanızı girin ve yeni bir parola seçin",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "Onaylama sonucu bilinmiyor ve oturum artık mevcut değil. SQL'i yeniden çalıştırmadan önce verileri doğrulayın.",
|
||||
moreActions: "Diğer İşlemler",
|
||||
newConnection: "Yeni Bağlantı",
|
||||
newQuery: "Yeni Sorgu",
|
||||
@@ -236,6 +237,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "Çoklu veritabanı yürütme",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "Manuel işlem",
|
||||
manualWarning: "Her hedef ayrı bir işlem kullanır ve ayrı ayrı onaylanır veya geri alınır. Veritabanları arası onaylama atomik değildir. DDL ve açık işlem komutları kendiliğinden onaylayabilir. İptal, geri almadan önce etkin ifadenin bitmesini bekler.",
|
||||
pendingCommit: "Onay bekleniyor",
|
||||
rolledBack: "Geri alındı",
|
||||
commit: "Onayla",
|
||||
rollback: "Geri al",
|
||||
manualUnsupported: "Bu hedef manuel işlemleri desteklemiyor.",
|
||||
manualCommitUnavailable: "Onaylama kullanılamıyor; işlemi geri alın.",
|
||||
discardTitle: "Bekleyen değişiklikler geri alınsın mı?",
|
||||
discardWarning: "Geri almadan önce etkin ifadeler tamamlanır. Önceden onaylanan hedefler geri alınamaz.",
|
||||
rollbackAndClose: "Geri al ve kapat",
|
||||
cleanupFailed: "Bazı işlemler geri alınamadı.",
|
||||
title: "Çoklu veritabanı yürütme",
|
||||
progress: "Yürütme ilerlemesi ({completed}/{total})",
|
||||
sqlPreview: "SQL önizlemesi",
|
||||
|
||||
@@ -243,6 +243,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "输入当前密码并设置新密码",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "提交结果未知,事务会话已不存在。请核对数据后再决定是否重新执行 SQL。",
|
||||
moreActions: "更多操作",
|
||||
newConnection: "新建连接",
|
||||
newQuery: "新建查询",
|
||||
@@ -293,6 +294,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "多库执行",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "手动事务",
|
||||
manualWarning: "每个目标使用独立事务,分别提交或回滚,不保证多个数据库一起提交成功。DDL 和显式事务语句可能自行提交。取消时会等待当前语句结束再回滚。",
|
||||
pendingCommit: "待提交",
|
||||
rolledBack: "已回滚",
|
||||
commit: "提交",
|
||||
rollback: "回滚",
|
||||
manualUnsupported: "此目标不支持手动事务。",
|
||||
manualCommitUnavailable: "无法提交,请回滚此事务。",
|
||||
discardTitle: "回滚未提交的更改?",
|
||||
discardWarning: "将等待执行中的语句结束再回滚,已提交的目标无法撤销。",
|
||||
rollbackAndClose: "回滚并关闭",
|
||||
cleanupFailed: "部分事务回滚失败。",
|
||||
title: "多库执行",
|
||||
progress: "执行进度({completed}/{total})",
|
||||
sqlPreview: "SQL 预览",
|
||||
@@ -5804,6 +5817,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "确定删除 HBase 表 {table} 吗?其中所有行都会被永久删除。",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "回滚 SQL 等待提交,可提交或撤销本次修改。",
|
||||
discardRollback: "撤销本次回滚",
|
||||
manualRollbackHint: "回滚 SQL 等待手动提交,DDL 仍可能隐式提交。",
|
||||
transactionEnded: "事务已结束或已回滚,未记录回滚成功。",
|
||||
rollbackBeforeClose: "撤销尚未提交的回滚修改并关闭历史记录?",
|
||||
title: "历史",
|
||||
search: "搜索历史...",
|
||||
conversationSearch: "搜索历史会话...",
|
||||
@@ -6510,6 +6528,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "即将推出",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "修改等待手动提交或回滚,出错时回滚。文件中的 DDL 和 COMMIT 仍可能提交修改。",
|
||||
manualCancelHint: "取消会等待当前语句结束,再回滚未提交的修改。",
|
||||
pendingTransaction: "等待提交或回滚",
|
||||
rollbackBeforeClose: "尚有未提交的修改。回滚并关闭?",
|
||||
transactionEnded: "事务已结束或已回滚,如有需要请重新执行文件。",
|
||||
restoreScope: "恢复范围",
|
||||
restoreAll: "全部内容",
|
||||
restoreSelectedTables: "指定表",
|
||||
@@ -6934,6 +6957,10 @@ export default withEnglishFallback({
|
||||
noTables: "没有可用表",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "修改等待手动提交或回滚,出错时回滚事务。",
|
||||
pendingTransaction: "等待提交或回滚",
|
||||
rollbackBeforeClose: "尚有未提交的修改。回滚并关闭?",
|
||||
transactionEnded: "事务已结束或已回滚,请重新比较后再执行。",
|
||||
title: "比较数据",
|
||||
selectTable: "选择表",
|
||||
searchTable: "搜索表名...",
|
||||
|
||||
@@ -321,6 +321,7 @@ export default withEnglishFallback({
|
||||
changePasswordDescription: "輸入目前密碼並設定新密碼",
|
||||
},
|
||||
toolbar: {
|
||||
commitOutcomeUnknown: "提交結果未知,交易工作階段已不存在。請核對資料後再決定是否重新執行 SQL。",
|
||||
moreActions: "更多操作",
|
||||
newConnection: "建立連線",
|
||||
newQuery: "建立查詢",
|
||||
@@ -371,6 +372,18 @@ export default withEnglishFallback({
|
||||
multiDbExecute: "多庫執行",
|
||||
},
|
||||
multiDbExecute: {
|
||||
manualTransaction: "手動交易",
|
||||
manualWarning: "每個目標使用獨立交易,分別提交或回復,不保證多個資料庫一起提交成功。DDL 和明確的交易陳述式可能自行提交。取消時會等待目前陳述式結束再回復。",
|
||||
pendingCommit: "待提交",
|
||||
rolledBack: "已回復",
|
||||
commit: "提交",
|
||||
rollback: "回復",
|
||||
manualUnsupported: "此目標不支援手動交易。",
|
||||
manualCommitUnavailable: "無法提交,請回復此交易。",
|
||||
discardTitle: "回復未提交的變更?",
|
||||
discardWarning: "將等待執行中的陳述式結束再回復,已提交的目標無法撤銷。",
|
||||
rollbackAndClose: "回復並關閉",
|
||||
cleanupFailed: "部分交易回復失敗。",
|
||||
title: "多資料庫執行",
|
||||
progress: "執行進度({completed}/{total})",
|
||||
sqlPreview: "SQL 預覽",
|
||||
@@ -4851,6 +4864,11 @@ export default withEnglishFallback({
|
||||
deleteTableConfirm: "確定要刪除 HBase 資料表 {table} 嗎?其中所有資料列都會永久刪除。",
|
||||
},
|
||||
history: {
|
||||
pendingRollback: "回滾 SQL 等待提交,可提交或撤銷本次修改。",
|
||||
discardRollback: "撤銷本次回滾",
|
||||
manualRollbackHint: "回滾 SQL 等待手動提交,DDL 仍可能隱式提交。",
|
||||
transactionEnded: "交易已結束或已回滾,未記錄回滾成功。",
|
||||
rollbackBeforeClose: "撤銷尚未提交的回滾修改並關閉歷史記錄?",
|
||||
title: "歷史",
|
||||
search: "搜尋歷史……",
|
||||
conversationSearch: "搜尋歷史會話……",
|
||||
@@ -5472,6 +5490,11 @@ export default withEnglishFallback({
|
||||
generateDataComingSoon: "即將推出",
|
||||
},
|
||||
sqlFile: {
|
||||
manualTransactionHint: "修改等待手動提交或回復,出錯時回復。檔案中的 DDL 和 COMMIT 仍可能提交修改。",
|
||||
manualCancelHint: "取消會等待目前陳述式結束,再回復未提交的修改。",
|
||||
pendingTransaction: "等待提交或回復",
|
||||
rollbackBeforeClose: "尚有未提交的修改。回復並關閉?",
|
||||
transactionEnded: "交易已結束或已回復,如有需要請重新執行檔案。",
|
||||
restoreScope: "還原範圍",
|
||||
restoreAll: "全部內容",
|
||||
restoreSelectedTables: "指定資料表",
|
||||
@@ -5853,6 +5876,10 @@ export default withEnglishFallback({
|
||||
noTables: "沒有可用的資料表",
|
||||
},
|
||||
dataCompare: {
|
||||
manualTransactionHint: "修改等待手動提交或回滾,發生錯誤時回滾交易。",
|
||||
pendingTransaction: "等待提交或回滾",
|
||||
rollbackBeforeClose: "尚有未提交的修改。回滾並關閉?",
|
||||
transactionEnded: "交易已結束或已回滾,請重新比較後再執行。",
|
||||
title: "比較資料",
|
||||
selectTable: "選擇資料表",
|
||||
searchTable: "搜尋資料表……",
|
||||
|
||||
@@ -4867,6 +4867,7 @@ export interface SqlFileRequest {
|
||||
database: string;
|
||||
filePath: string;
|
||||
continueOnError: boolean;
|
||||
txnSessionId?: string;
|
||||
selectedTables?: SqlFileTable[];
|
||||
partCooldownMs?: number;
|
||||
skipRelationalConstraints?: boolean;
|
||||
|
||||
@@ -1417,6 +1417,11 @@ export const useQueryStore = defineStore("query", () => {
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: undefined,
|
||||
});
|
||||
// Workers must not inherit result navigation or write targets from the
|
||||
// source editor, including when a dedicated transaction supplies results.
|
||||
clearResultPayload(worker);
|
||||
clearResultNavigationState(worker);
|
||||
worker.resultBaseSql = undefined;
|
||||
multiDbExecutionWorkers.set(id, worker);
|
||||
const workers = multiDbExecutionWorkerScopes.get(scopeId) ?? new Set<string>();
|
||||
workers.add(id);
|
||||
|
||||
@@ -19,7 +19,14 @@ export interface SqlExecutionTargetGroup {
|
||||
|
||||
export type SqlExecutionTargetValidationState = "valid" | "invalid" | "needsRecheck";
|
||||
|
||||
export type MultiDbExecutionItemStatus = "pending" | "running" | "success" | "failed" | "skipped" | "cancelled" | "not_executed";
|
||||
export type MultiDbExecutionItemStatus = "pending" | "running" | "pending_commit" | "rolled_back" | "success" | "failed" | "skipped" | "cancelled" | "not_executed";
|
||||
|
||||
/** An individual target owns its session until it is explicitly settled. */
|
||||
export interface MultiDbManualTransaction {
|
||||
canCommit: boolean;
|
||||
/** Returns a warning if the session ended but its commit outcome is unknown. */
|
||||
finish: (action: "commit" | "rollback") => Promise<string | void>;
|
||||
}
|
||||
|
||||
export interface MultiDbResultRunExecution {
|
||||
kind: "multi-db";
|
||||
@@ -35,6 +42,7 @@ export interface MultiDbTargetExecutionResult {
|
||||
status: Exclude<MultiDbExecutionItemStatus, "pending" | "running" | "not_executed">;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
transaction?: MultiDbManualTransaction;
|
||||
}
|
||||
|
||||
export interface SqlExecutionTargetValidation {
|
||||
|
||||
@@ -19,7 +19,8 @@ pub fn clamp_sql_file_upload_max_mb(value: u32) -> u32 {
|
||||
value.clamp(1, MAX_SQL_FILE_UPLOAD_MAX_MB)
|
||||
}
|
||||
use crate::query::{
|
||||
execute_sql_statement_with_options, pool_error_action, wait_for_query_opt, DbOperationBudget, PoolErrorAction,
|
||||
execute_in_manual_transaction, execute_sql_statement_with_options, keep_manual_transaction_alive,
|
||||
pool_error_action, rollback_manual_transaction, wait_for_query_opt, DbOperationBudget, PoolErrorAction,
|
||||
QueryExecutionOptions,
|
||||
};
|
||||
use crate::sql::{
|
||||
@@ -241,6 +242,9 @@ impl MySqlSqlFileExecutor {
|
||||
request: &SqlFileRequest,
|
||||
import_target: Option<&SqlFileImportTarget>,
|
||||
) -> Result<Option<Self>, String> {
|
||||
if request.txn_session_id.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(target) = import_target else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -583,7 +587,67 @@ async fn set_relational_constraints_enabled(
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn sql_file_transaction_schema(state: &AppState, request: &SqlFileRequest) -> Result<Option<String>, String> {
|
||||
let Some(session_id) = request.txn_session_id.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let sessions = state.transaction_sessions.read().await;
|
||||
let session =
|
||||
sessions.get(session_id).ok_or("SQL file transaction session not found; no statements were retried")?;
|
||||
if session.connection_id != request.connection_id || session.database != request.database {
|
||||
return Err("SQL file target does not match its manual transaction".to_string());
|
||||
}
|
||||
Ok(session.schema.clone())
|
||||
}
|
||||
|
||||
async fn with_sql_file_transaction<T>(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
token: &CancellationToken,
|
||||
execution: impl std::future::Future<Output = Result<T, String>>,
|
||||
) -> Result<T, String> {
|
||||
let Some(session_id) = request.txn_session_id.as_deref() else {
|
||||
return execution.await;
|
||||
};
|
||||
sql_file_transaction_schema(state, request).await?;
|
||||
if request.continue_on_error || request.skip_relational_constraints {
|
||||
return Err("Manual SQL file execution cannot continue after an error or disable constraints".to_string());
|
||||
}
|
||||
let _keep_alive = keep_manual_transaction_alive(state, session_id).await?;
|
||||
let result = execution.await;
|
||||
let should_rollback =
|
||||
(result.is_err() || token.is_cancelled()) && state.transaction_sessions.read().await.contains_key(session_id);
|
||||
if should_rollback {
|
||||
// Never drop an in-flight database future on cancellation. Finish that
|
||||
// round trip, stop before the next statement, then roll back the session.
|
||||
if let Err(rollback_error) = rollback_manual_transaction(state, session_id).await {
|
||||
return Err(format!(
|
||||
"{}. Rollback failed: {rollback_error}",
|
||||
result.err().unwrap_or_else(|| "SQL file cancelled".to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn execute_sql_file_content(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
file_content: &str,
|
||||
token: CancellationToken,
|
||||
started_at: Instant,
|
||||
emit: impl FnMut(SqlFileProgress),
|
||||
) -> Result<(), String> {
|
||||
with_sql_file_transaction(
|
||||
state,
|
||||
request,
|
||||
&token,
|
||||
Box::pin(execute_sql_file_content_inner(state, request, file_content, token.clone(), started_at, emit)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_sql_file_content_inner(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
file_content: &str,
|
||||
@@ -609,10 +673,11 @@ pub async fn execute_sql_file_content(
|
||||
filter_restore_statements(&mut statements, &mut TableRestoreFilter::with_views(views), selected)?;
|
||||
}
|
||||
|
||||
let planned_statements = optimize_controlled_sql_file_import_statements(
|
||||
let planned_statements = plan_sql_file_statements(
|
||||
&statements,
|
||||
import_target.as_ref().map(|target| target.db_type),
|
||||
import_target.as_ref().and_then(|target| target.driver_profile.as_deref()),
|
||||
request.txn_session_id.is_some(),
|
||||
);
|
||||
// MySQL-family imports need one pinned connection so `USE` and session
|
||||
// state survive across the whole file.
|
||||
@@ -677,6 +742,23 @@ pub async fn execute_sql_file_path(
|
||||
/// state such as `USE`, temporary tables, variables, and transactions remains
|
||||
/// available to the next file in the batch.
|
||||
pub async fn execute_sql_file_paths(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
file_paths: &[&Path],
|
||||
token: CancellationToken,
|
||||
started_at: Instant,
|
||||
emit: impl FnMut(SqlFileProgress),
|
||||
) -> Result<(), String> {
|
||||
with_sql_file_transaction(
|
||||
state,
|
||||
request,
|
||||
&token,
|
||||
Box::pin(execute_sql_file_paths_inner(state, request, file_paths, token.clone(), started_at, emit)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_sql_file_paths_inner(
|
||||
state: &AppState,
|
||||
request: &SqlFileRequest,
|
||||
file_paths: &[&Path],
|
||||
@@ -1657,10 +1739,11 @@ async fn execute_sql_file_statement_batch(
|
||||
return Ok(());
|
||||
}
|
||||
let statements = std::mem::take(statements);
|
||||
let planned_statements = optimize_controlled_sql_file_import_statements(
|
||||
let planned_statements = plan_sql_file_statements(
|
||||
&statements,
|
||||
import_target.map(|target| target.db_type),
|
||||
import_target.and_then(|target| target.driver_profile.as_deref()),
|
||||
request.txn_session_id.is_some(),
|
||||
);
|
||||
execute_planned_statements_with_progress(
|
||||
state,
|
||||
@@ -1726,6 +1809,28 @@ fn split_sql_file_import_statements_with_control(
|
||||
statements
|
||||
}
|
||||
|
||||
fn plan_sql_file_statements(
|
||||
statements: &[SqlStatementWithControl],
|
||||
db_type: Option<DatabaseType>,
|
||||
driver_profile: Option<&str>,
|
||||
manual: bool,
|
||||
) -> Vec<ControlledSqlFileImportStatement> {
|
||||
if !manual {
|
||||
return optimize_controlled_sql_file_import_statements(statements, db_type, driver_profile);
|
||||
}
|
||||
// Preserve each statement and its error boundary. The normal import path
|
||||
// may merge INSERTs and retry them individually, which is not valid after
|
||||
// a failed manual transaction has been rolled back.
|
||||
statements
|
||||
.iter()
|
||||
.flat_map(|statement| {
|
||||
optimize_sql_file_import_statements(std::slice::from_ref(&statement.sql), db_type, driver_profile)
|
||||
.into_iter()
|
||||
.map(|statement| ControlledSqlFileImportStatement { statement, stop_on_error: true })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn optimize_controlled_sql_file_import_statements(
|
||||
statements: &[SqlStatementWithControl],
|
||||
db_type: Option<DatabaseType>,
|
||||
@@ -2346,6 +2451,19 @@ async fn execute_sql_file_statement(
|
||||
token: &CancellationToken,
|
||||
statement_index: usize,
|
||||
) -> Result<QueryResult, String> {
|
||||
if let Some(session_id) = request.txn_session_id.as_deref() {
|
||||
let schema = sql_file_transaction_schema(state, request).await?;
|
||||
let mut results =
|
||||
execute_in_manual_transaction(state, session_id, sql, &request.database, schema.as_deref(), Some(1))
|
||||
.await?
|
||||
.into_iter();
|
||||
let mut result = results.next().ok_or("SQL file statement returned no result")?;
|
||||
for next in results {
|
||||
result.affected_rows += next.affected_rows;
|
||||
result.execution_time_ms += next.execution_time_ms;
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
let execution_id = sql_file_statement_execution_id(&request.execution_id, statement_index);
|
||||
let registered = state.running_queries.register(execution_id.clone());
|
||||
let child_token = registered.token();
|
||||
@@ -2454,6 +2572,53 @@ mod tests {
|
||||
|
||||
static TEMP_SQL_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn manual_file_plan_keeps_insert_error_boundaries() {
|
||||
let statements = split_sql_file_import_statements_with_control(
|
||||
"INSERT INTO t VALUES (1); INSERT INTO t VALUES (2);",
|
||||
Some(DatabaseType::Postgres),
|
||||
None,
|
||||
);
|
||||
let optimized = plan_sql_file_statements(&statements, Some(DatabaseType::Postgres), None, false);
|
||||
assert_eq!(optimized.len(), 1);
|
||||
let manual = plan_sql_file_statements(&statements, Some(DatabaseType::Postgres), None, true);
|
||||
assert_eq!(manual.len(), 2);
|
||||
assert!(manual.iter().all(|item| item.stop_on_error && item.statement.source_statement_count == 1));
|
||||
assert_eq!(manual[0].statement.sql, statements[0].sql);
|
||||
assert_eq!(manual[1].statement.sql, statements[1].sql);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_file_missing_session_never_falls_back_to_ordinary_execution() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let storage = crate::storage::Storage::open(&directory.path().join("storage.db")).await.unwrap();
|
||||
let state = AppState::new(storage);
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: Some("expired-session".to_string()),
|
||||
execution_id: "file-missing-session".to_string(),
|
||||
connection_id: "unconfigured".to_string(),
|
||||
database: String::new(),
|
||||
file_path: String::new(),
|
||||
continue_on_error: false,
|
||||
selected_tables: None,
|
||||
part_cooldown_ms: 0,
|
||||
skip_relational_constraints: false,
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
let error = execute_sql_file_content(
|
||||
&state,
|
||||
&request,
|
||||
"INSERT INTO t VALUES (1);",
|
||||
CancellationToken::new(),
|
||||
Instant::now(),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.contains("transaction session not found"));
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
async fn temporary_sql_file(bytes: &[u8]) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"dbx-sql-file-{}-{}.sql",
|
||||
@@ -2718,6 +2883,7 @@ mod tests {
|
||||
] {
|
||||
let path = temporary_sql_file(&bytes).await;
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: "file-progress".to_string(),
|
||||
connection_id: "unconfigured".to_string(),
|
||||
database: String::new(),
|
||||
@@ -3082,6 +3248,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: "gauss-stop-on-error".to_string(),
|
||||
connection_id: "gauss-stream".to_string(),
|
||||
database: String::new(),
|
||||
|
||||
@@ -7073,7 +7073,6 @@ mod tests {
|
||||
assert!(!is_destructive_schema_diff_statement("SELECT 'DROP TABLE users'"));
|
||||
assert!(!is_destructive_schema_diff_statement("ALTER TABLE \"DROP INDEX audit\" ADD COLUMN note TEXT"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
use crate::db::agent_driver::{AgentDriverClient, AgentLaunchSpec};
|
||||
use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType};
|
||||
#[cfg(unix)]
|
||||
@@ -10683,7 +10682,6 @@ for line in sys.stdin:
|
||||
/// Spawns a fake Python agent and registers a manual transaction session in
|
||||
/// the app state so `execute_in_manual_transaction_with_options` can run
|
||||
/// end to end without a live database.
|
||||
#[cfg(unix)]
|
||||
async fn manual_transaction_test_state(db_type: DatabaseType) -> (AppState, String, std::path::PathBuf) {
|
||||
use std::io::Write;
|
||||
|
||||
@@ -10699,6 +10697,9 @@ for line in sys.stdin:
|
||||
method = request.get("method")
|
||||
if method == "execute_query":
|
||||
sql = request.get("params", {{}}).get("sql", "")
|
||||
if "RAISE_FILE_ERROR" in sql:
|
||||
print(json.dumps({{"jsonrpc": "2.0", "id": request["id"], "error": {{"code": -32000, "message": "file statement rejected"}}}}), flush=True)
|
||||
continue
|
||||
row = [sql]
|
||||
else:
|
||||
# commit_manual_transaction / rollback_manual_transaction / disconnect
|
||||
@@ -10720,8 +10721,9 @@ for line in sys.stdin:
|
||||
.unwrap();
|
||||
script.flush().unwrap();
|
||||
|
||||
let python = if cfg!(windows) { "python" } else { "python3" };
|
||||
let client = AgentDriverClient::spawn(
|
||||
AgentLaunchSpec::new("python3").with_args([script.path().to_string_lossy().to_string()]),
|
||||
AgentLaunchSpec::new(python).with_args([script.path().to_string_lossy().to_string()]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -10764,6 +10766,162 @@ for line in sys.stdin:
|
||||
(state, txn_session_id, dir)
|
||||
}
|
||||
|
||||
fn manual_sql_file_request(session_id: &str) -> crate::sql::SqlFileRequest {
|
||||
crate::sql::SqlFileRequest {
|
||||
txn_session_id: Some(session_id.to_string()),
|
||||
execution_id: "manual-file-test".to_string(),
|
||||
connection_id: "agent-conn".to_string(),
|
||||
database: "ORCL".to_string(),
|
||||
file_path: String::new(),
|
||||
continue_on_error: false,
|
||||
selected_tables: None,
|
||||
part_cooldown_ms: 0,
|
||||
skip_relational_constraints: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_sql_file_retains_session_until_commit() {
|
||||
let (state, session_id, dir) = manual_transaction_test_state(DatabaseType::OceanbaseOracle).await;
|
||||
let request = manual_sql_file_request(&session_id);
|
||||
let mut events = Vec::new();
|
||||
// Only the held transaction has a connected agent. A fallback through
|
||||
// the ordinary pool cannot execute these statements successfully.
|
||||
crate::data::sql_file_import::execute_sql_file_content(
|
||||
&state,
|
||||
&request,
|
||||
"UPDATE T SET V = 1; UPDATE T SET V = 2;",
|
||||
CancellationToken::new(),
|
||||
std::time::Instant::now(),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let terminal = events.last().unwrap();
|
||||
assert_eq!(terminal.status, crate::sql::SqlFileStatus::Done);
|
||||
assert_eq!(terminal.success_count, 2);
|
||||
assert!(state.transaction_sessions.read().await.contains_key(&session_id));
|
||||
commit_manual_transaction(&state, &session_id).await.unwrap();
|
||||
assert!(!state.transaction_sessions.read().await.contains_key(&session_id));
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_sql_file_cancel_stops_next_statement_and_releases_session() {
|
||||
let (state, session_id, dir) = manual_transaction_test_state(DatabaseType::OceanbaseOracle).await;
|
||||
let request = manual_sql_file_request(&session_id);
|
||||
let token = CancellationToken::new();
|
||||
let mut events = Vec::new();
|
||||
crate::data::sql_file_import::execute_sql_file_content(
|
||||
&state,
|
||||
&request,
|
||||
"UPDATE T SET V = 1; UPDATE T SET V = 2;",
|
||||
token.clone(),
|
||||
std::time::Instant::now(),
|
||||
|event| {
|
||||
if event.success_count == 1 {
|
||||
token.cancel();
|
||||
}
|
||||
events.push(event);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let terminal = events.last().unwrap();
|
||||
assert_eq!(terminal.status, crate::sql::SqlFileStatus::Cancelled);
|
||||
assert_eq!(terminal.success_count, 1);
|
||||
assert!(!state.transaction_sessions.read().await.contains_key(&session_id));
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_sql_file_read_failure_rolls_back_held_session() {
|
||||
let (state, session_id, dir) = manual_transaction_test_state(DatabaseType::OceanbaseOracle).await;
|
||||
let request = manual_sql_file_request(&session_id);
|
||||
let result = crate::data::sql_file_import::execute_sql_file_path(
|
||||
&state,
|
||||
&request,
|
||||
&dir.join("missing.sql"),
|
||||
CancellationToken::new(),
|
||||
std::time::Instant::now(),
|
||||
|_| {},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(!state.transaction_sessions.read().await.contains_key(&session_id));
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_sql_file_statement_failure_rolls_back_without_retry_or_later_execution() {
|
||||
let (state, session_id, dir) = manual_transaction_test_state(DatabaseType::OceanbaseOracle).await;
|
||||
let request = manual_sql_file_request(&session_id);
|
||||
let mut events = Vec::new();
|
||||
let error = crate::data::sql_file_import::execute_sql_file_content(
|
||||
&state,
|
||||
&request,
|
||||
"UPDATE T SET V = 1; UPDATE RAISE_FILE_ERROR SET V = 2; UPDATE T SET V = 3;",
|
||||
CancellationToken::new(),
|
||||
std::time::Instant::now(),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.contains("file statement rejected"));
|
||||
let terminal = events.last().unwrap();
|
||||
assert_eq!(terminal.status, crate::sql::SqlFileStatus::Error);
|
||||
assert_eq!(terminal.success_count, 1);
|
||||
assert_eq!(terminal.failure_count, 1);
|
||||
assert_eq!(terminal.statement_index, 2);
|
||||
assert!(!state.transaction_sessions.read().await.contains_key(&session_id));
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_sql_file_rejects_different_target_without_using_or_closing_its_session() {
|
||||
let (state, session_id, dir) = manual_transaction_test_state(DatabaseType::OceanbaseOracle).await;
|
||||
for mismatch_connection in [false, true] {
|
||||
let mut request = manual_sql_file_request(&session_id);
|
||||
if mismatch_connection {
|
||||
request.connection_id = "other".to_string();
|
||||
} else {
|
||||
request.database = "other".to_string();
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let error = crate::data::sql_file_import::execute_sql_file_content(
|
||||
&state,
|
||||
&request,
|
||||
"UPDATE T SET V = 1;",
|
||||
CancellationToken::new(),
|
||||
std::time::Instant::now(),
|
||||
|event| events.push(event),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.contains("target does not match"));
|
||||
assert!(events.is_empty());
|
||||
assert!(state.transaction_sessions.read().await.contains_key(&session_id));
|
||||
}
|
||||
rollback_manual_transaction(&state, &session_id).await.unwrap();
|
||||
let error = crate::data::sql_file_import::execute_sql_file_content(
|
||||
&state,
|
||||
&manual_sql_file_request(&session_id),
|
||||
"UPDATE T SET V = 1;",
|
||||
CancellationToken::new(),
|
||||
std::time::Instant::now(),
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.contains("session not found"));
|
||||
drop(state);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Regression: multi-statement scripts under a manual transaction must keep
|
||||
/// executing sequentially even when pagination options are set — they are
|
||||
/// ignored instead of failing the script (single-statement pagination stays
|
||||
|
||||
@@ -1285,6 +1285,7 @@ INSERT INTO install_check (id) VALUES (1), (2);
|
||||
"#
|
||||
);
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("exec-{suffix}"),
|
||||
connection_id: config.id.clone(),
|
||||
database: String::new(),
|
||||
@@ -1373,6 +1374,7 @@ INSERT INTO children (parent_id) VALUES (LAST_INSERT_ID());
|
||||
"#
|
||||
);
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("exec-{suffix}"),
|
||||
connection_id: config.id.clone(),
|
||||
database: String::new(),
|
||||
@@ -1448,6 +1450,7 @@ async fn live_sql_file_import_preserves_raw_mysql_binary_literal_bytes() {
|
||||
script.extend_from_slice(&[0xAC, b'\\', 0xED, b'\\', b'0', 0x05]);
|
||||
script.extend_from_slice(b"');\n");
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("exec-{suffix}"),
|
||||
connection_id: config.id.clone(),
|
||||
database: String::new(),
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn live_mysql_selected_table_restore_preserves_unselected_tables() {
|
||||
let tables = inspect_sql_file_tables(&path).await?;
|
||||
assert_eq!(tables.len(), 2);
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("restore-{suffix}-{compressed}"), connection_id: connection_id.clone(), database: database.clone(), file_path: path.display().to_string(), continue_on_error: false,
|
||||
selected_tables: Some(vec![SqlFileTable { database: Some(database.clone()), name: "chosen".into() }]),
|
||||
part_cooldown_ms: 0,
|
||||
@@ -90,6 +91,7 @@ async fn live_mysql_selected_table_restore_preserves_unselected_tables() {
|
||||
let path = dir.join("unsupported.sql");
|
||||
std::fs::write(&path, "DROP TABLE chosen; INSERT INTO chosen VALUES (1, 'bad'); CALL unexpected();").map_err(|e| e.to_string())?;
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("invalid-{suffix}"), connection_id: connection_id.clone(), database: database.clone(), file_path: path.display().to_string(), continue_on_error: true,
|
||||
selected_tables: Some(vec![SqlFileTable { database: None, name: "chosen".into() }]),
|
||||
part_cooldown_ms: 0,
|
||||
@@ -169,6 +171,7 @@ async fn live_mysql_database_export_restores_dependent_views() {
|
||||
|
||||
execute_sql_statement(&state, &connection_id, "", &format!("DROP DATABASE `{database}`"), None, None).await?;
|
||||
let import_request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("live-mysql-import-{suffix}"),
|
||||
connection_id: connection_id.clone(),
|
||||
database: String::new(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Run explicitly with DBX_LIVE_SQL_FILE_OB_{HOST,USER,PASSWORD,DATABASE,AGENT_DIR}.
|
||||
//! The agent directory must contain a compatible installed OceanBase Oracle driver and JRE.
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::data::sql_file_import::{execute_sql_file_content, execute_sql_file_paths};
|
||||
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use dbx_core::query::{
|
||||
begin_manual_transaction, commit_manual_transaction, execute_sql_statement, rollback_manual_transaction,
|
||||
};
|
||||
use dbx_core::sql::{SqlFileRequest, SqlFileStatus};
|
||||
use dbx_core::storage::Storage;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn required(name: &str) -> String {
|
||||
std::env::var(format!("DBX_LIVE_SQL_FILE_OB_{name}")).expect("required live SQL file test configuration")
|
||||
}
|
||||
|
||||
async fn query(
|
||||
state: &AppState,
|
||||
connection: &str,
|
||||
database: &str,
|
||||
sql: &str,
|
||||
) -> Result<dbx_core::types::QueryResult, String> {
|
||||
execute_sql_statement(state, connection, database, sql, None, None).await
|
||||
}
|
||||
|
||||
async fn verify_committed_value(state: &AppState, database: &str, table: &str, expected: i64) -> Result<(), String> {
|
||||
let result = query(state, "file-reader", database, &format!("SELECT VAL FROM {table} WHERE ID = 1")).await?;
|
||||
let value = result.rows.first().and_then(|row| row.first());
|
||||
let actual = value.and_then(|value| value.as_i64().or_else(|| value.as_str()?.parse().ok()));
|
||||
if actual != Some(expected) {
|
||||
return Err(format!("Independent connection expected {expected}, got {value:?}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed OceanBase Oracle agent and writable DBX_LIVE_SQL_FILE_OB_* environment"]
|
||||
async fn live_oceanbase_sql_files_commit_rollback_failure_and_cancel() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::open(&directory.path().join("state.db")).await.unwrap();
|
||||
let state = AppState::new_with_plugin_and_agent_dir_and_app_version(
|
||||
storage,
|
||||
directory.path().join("plugins"),
|
||||
required("AGENT_DIR").into(),
|
||||
"0.6.17",
|
||||
);
|
||||
let database = required("DATABASE");
|
||||
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
|
||||
"id": "file-writer", "name": "SQL file transaction regression", "db_type": DatabaseType::OceanbaseOracle,
|
||||
"host": required("HOST"), "port": std::env::var("DBX_LIVE_SQL_FILE_OB_PORT").ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(2881),
|
||||
"username": required("USER"), "password": required("PASSWORD"), "database": database,
|
||||
"connect_timeout_secs": 10, "query_timeout_secs": 30, "keepalive_interval_secs": 0,
|
||||
})).unwrap();
|
||||
let mut reader = config.clone();
|
||||
reader.id = "file-reader".to_string();
|
||||
state.configs.write().await.insert(config.id.clone(), config);
|
||||
state.configs.write().await.insert(reader.id.clone(), reader);
|
||||
let table = format!("DBX_FILE_TXN_{}", &uuid::Uuid::new_v4().simple().to_string()[..16]).to_uppercase();
|
||||
query(&state, "file-writer", &database, &format!("CREATE TABLE {table} (ID NUMBER PRIMARY KEY, VAL NUMBER)"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut phase = "seed";
|
||||
let outcome: Result<(), String> = async {
|
||||
query(&state, "file-writer", &database, &format!("INSERT INTO {table} VALUES (1, 10)")).await?;
|
||||
let mut request = SqlFileRequest {
|
||||
execution_id: "live-manual-file".to_string(), connection_id: "file-writer".to_string(),
|
||||
database: database.clone(), file_path: String::new(), continue_on_error: false,
|
||||
selected_tables: None, part_cooldown_ms: 0, skip_relational_constraints: false, txn_session_id: None,
|
||||
};
|
||||
let first = directory.path().join("first.sql");
|
||||
let second = directory.path().join("second.sql");
|
||||
std::fs::write(&first, format!("UPDATE {table} SET VAL = 20 WHERE ID = 1;\n")).unwrap();
|
||||
std::fs::write(&second, format!("UPDATE {table} SET VAL = 21 WHERE ID = 1;\n")).unwrap();
|
||||
phase = "begin transaction";
|
||||
let session = begin_manual_transaction(&state, "file-writer", &database, None, None).await?;
|
||||
request.txn_session_id = Some(session.clone());
|
||||
phase = "execute multiple files";
|
||||
execute_sql_file_paths(&state, &request, &[&first, &second], CancellationToken::new(), Instant::now(), |_| {}).await?;
|
||||
phase = "read from independent connection";
|
||||
verify_committed_value(&state, &database, &table, 10).await?;
|
||||
phase = "commit";
|
||||
commit_manual_transaction(&state, &session).await?;
|
||||
phase = "read from independent connection";
|
||||
verify_committed_value(&state, &database, &table, 21).await?;
|
||||
|
||||
phase = "begin transaction";
|
||||
let session = begin_manual_transaction(&state, "file-writer", &database, None, None).await?;
|
||||
request.txn_session_id = Some(session.clone());
|
||||
phase = "execute file content";
|
||||
execute_sql_file_content(&state, &request, &format!("UPDATE {table} SET VAL = 30 WHERE ID = 1;"), CancellationToken::new(), Instant::now(), |_| {}).await?;
|
||||
phase = "read from independent connection";
|
||||
verify_committed_value(&state, &database, &table, 21).await?;
|
||||
phase = "rollback";
|
||||
rollback_manual_transaction(&state, &session).await?;
|
||||
phase = "read from independent connection";
|
||||
verify_committed_value(&state, &database, &table, 21).await?;
|
||||
|
||||
phase = "begin transaction";
|
||||
let session = begin_manual_transaction(&state, "file-writer", &database, None, None).await?;
|
||||
request.txn_session_id = Some(session.clone());
|
||||
let mut terminal = None;
|
||||
phase = "execute failing file";
|
||||
let failure = execute_sql_file_content(&state, &request, &format!("UPDATE {table} SET VAL = 40 WHERE ID = 1; UPDATE {table} SET MISSING_COLUMN = 1; UPDATE {table} SET VAL = 50;"), CancellationToken::new(), Instant::now(), |event| terminal = Some(event)).await;
|
||||
if failure.is_ok() || terminal.as_ref().is_none_or(|event| event.status != SqlFileStatus::Error || event.success_count != 1 || event.failure_count != 1) {
|
||||
return Err("A failed SQL file must stop after its first error".to_string());
|
||||
}
|
||||
if state.transaction_sessions.read().await.contains_key(&session) { return Err("Failed file retained its transaction".to_string()); }
|
||||
phase = "read from independent connection";
|
||||
verify_committed_value(&state, &database, &table, 21).await?;
|
||||
|
||||
phase = "begin transaction";
|
||||
let session = begin_manual_transaction(&state, "file-writer", &database, None, None).await?;
|
||||
request.txn_session_id = Some(session.clone());
|
||||
let token = CancellationToken::new();
|
||||
let mut terminal = None;
|
||||
phase = "execute file content";
|
||||
execute_sql_file_content(&state, &request, &format!("UPDATE {table} SET VAL = 60 WHERE ID = 1; UPDATE {table} SET VAL = 70 WHERE ID = 1;"), token.clone(), Instant::now(), |event| {
|
||||
if event.success_count == 1 { token.cancel(); }
|
||||
terminal = Some(event);
|
||||
}).await?;
|
||||
if terminal.is_none_or(|event| event.status != SqlFileStatus::Cancelled || event.success_count != 1) {
|
||||
return Err("Cancellation must stop before the second statement".to_string());
|
||||
}
|
||||
if state.transaction_sessions.read().await.contains_key(&session) { return Err("Cancelled file retained its transaction".to_string()); }
|
||||
phase = "read from independent connection";
|
||||
verify_committed_value(&state, &database, &table, 21).await
|
||||
}.await;
|
||||
|
||||
let remaining: Vec<String> = state.transaction_sessions.read().await.keys().cloned().collect();
|
||||
for session in remaining {
|
||||
let _ = rollback_manual_transaction(&state, &session).await;
|
||||
}
|
||||
let cleanup = query(&state, "file-writer", &database, &format!("DROP TABLE {table} PURGE")).await;
|
||||
state.shutdown(Duration::from_secs(10)).await;
|
||||
cleanup.expect("remove isolated regression table");
|
||||
outcome.unwrap_or_else(|error| panic!("SQL file manual transaction {phase}: {error}"));
|
||||
}
|
||||
@@ -144,6 +144,7 @@ async fn live_postgres_all_schema_export_restores_one_sql_file() {
|
||||
execute_sql_file_path(
|
||||
&state,
|
||||
&SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("postgres-all-schema-import-{suffix}"),
|
||||
connection_id: target_connection_id.clone(),
|
||||
database: target_database.clone(),
|
||||
|
||||
@@ -1291,6 +1291,7 @@ async fn live_sqlserver_sql_file_import_executes_go_batches() {
|
||||
GO"
|
||||
);
|
||||
let request = SqlFileRequest {
|
||||
txn_session_id: None,
|
||||
execution_id: format!("live-sqlserver-file-{suffix}"),
|
||||
connection_id: connection_id.to_string(),
|
||||
database: database.clone(),
|
||||
|
||||
@@ -1997,7 +1997,12 @@ impl AgentDriverClient {
|
||||
pub async fn disconnect(&mut self) -> Result<Value, String> {
|
||||
self.invalidate_cached_query();
|
||||
if self.shared_runtime.is_some() {
|
||||
let session_id = self.agent_session_id.as_ref().ok_or("Shared Agent session id is missing")?.clone();
|
||||
// A manual transaction can close its session before the detached
|
||||
// pool finishes cleanup. Closing it again must not be reported as
|
||||
// a runtime failure and terminate unrelated shared sessions.
|
||||
let Some(session_id) = self.agent_session_id.clone() else {
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
let result =
|
||||
self.call_method(AgentMethod::CloseSession, serde_json::json!({ "agentSessionId": session_id })).await;
|
||||
if result.is_ok() {
|
||||
@@ -4364,6 +4369,25 @@ for line in sys.stdin:
|
||||
let _ = std::fs::remove_file(script_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_session_disconnect_is_idempotent_and_preserves_sibling() {
|
||||
let (runtime, script_path) = spawn_stateful_test_runtime("repeat-disconnect-test").await;
|
||||
runtime.increment_session_count();
|
||||
runtime.increment_session_count();
|
||||
let mut closing = AgentDriverClient::shared_session(runtime.clone(), "closing-session".to_string());
|
||||
let mut sibling = AgentDriverClient::shared_session(runtime.clone(), "sibling-session".to_string());
|
||||
closing.disconnect().await.unwrap();
|
||||
assert_eq!(runtime.active_session_count(), 1);
|
||||
assert_eq!(closing.disconnect().await.unwrap(), serde_json::Value::Null);
|
||||
assert_eq!(runtime.active_session_count(), 1);
|
||||
let reply: serde_json::Value = sibling.call("probe", serde_json::json!({})).await.unwrap();
|
||||
assert_eq!(reply, serde_json::json!({"ok": true}));
|
||||
assert!(!runtime.is_failed());
|
||||
sibling.disconnect().await.unwrap();
|
||||
runtime.kill_and_wait().await;
|
||||
let _ = std::fs::remove_file(script_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_runtime_kill_and_wait_joins_existing_reaper() {
|
||||
let (runtime, script_path) = spawn_stateful_test_runtime("kill-and-wait-reap-test").await;
|
||||
|
||||
@@ -59,6 +59,9 @@ pub struct SqlFileRequest {
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub continue_on_error: bool,
|
||||
/// Reuse a held manual transaction instead of committing through ordinary query execution.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub txn_session_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_tables: Option<Vec<dbx_types::sql_file::SqlFileTable>>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -34,9 +34,3 @@ test("SQL file execution summary reserves space for aggregate columns", () => {
|
||||
assert.match(dialogSource, /<tfoot class="sticky bottom-0 z-10 border-t-2 border-primary\/35 bg-muted font-semibold text-foreground/);
|
||||
assert.match(dialogSource, /<th scope="row" class="px-2\.5 py-2 text-left">\{\{ t\("sqlFile\.totalFiles"/);
|
||||
});
|
||||
|
||||
test("SQL file execution dialog preserves cancel, close, and retry actions", () => {
|
||||
assert.match(dialogSource, /<template v-if="running">[\s\S]*@click="open = false"[\s\S]*@click="cancelExecution"/);
|
||||
assert.match(dialogSource, /<template v-else>[\s\S]*@click="open = false"[\s\S]*:disabled="!canStart" @click="startExecution"/);
|
||||
assert.match(dialogSource, /terminalStatus\.value = cancelRequested\.value \? "cancelled" : "error"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user